diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5b5a546d29..48b26f5228 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -19,6 +19,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt
index 75dcbc181c..dbde356c70 100644
--- a/app/src/main/java/app/gamenative/PrefManager.kt
+++ b/app/src/main/java/app/gamenative/PrefManager.kt
@@ -1300,6 +1300,14 @@ object PrefManager {
setPref(CUSTOM_GAME_MANUAL_FOLDERS, Json.encodeToString(value))
}
+ // Diagnostics launch mode (verbose wrapper/Wine logging). Persisted so setupXEnvironment can read it via
+ // context instead of threading it as a parameter through the (very large) XServerScreen composable — adding a
+ // parameter there pushed the method past ART's bytecode-verifier limit (VerifyError on launch). See MainViewModel.
+ private val WRAPPER_DIAGNOSTICS = booleanPreferencesKey("wrapper_diagnostics")
+ var wrapperDiagnostics: Boolean
+ get() = getPref(WRAPPER_DIAGNOSTICS, false)
+ set(value) = setPref(WRAPPER_DIAGNOSTICS, value)
+
// Add new setting for Wine debug logging
private val ENABLE_WINE_DEBUG = booleanPreferencesKey("enable_wine_debug")
var enableWineDebug: Boolean
diff --git a/app/src/main/java/app/gamenative/steamcontroller/ProfileInterpreter.kt b/app/src/main/java/app/gamenative/steamcontroller/ProfileInterpreter.kt
new file mode 100644
index 0000000000..0a33c1ca9e
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/ProfileInterpreter.kt
@@ -0,0 +1,1200 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.inputcontrols.GamepadState
+import com.winlator.xserver.Pointer
+import com.winlator.xserver.XKeycode
+import kotlin.math.abs
+import kotlin.math.hypot
+import kotlin.math.roundToInt
+
+/**
+ * Applies a [ScProfile] to each decoded [TritonState], driving an [ScOutputSink] (virtual XInput pad +
+ * mouse/keys). This is the profile-driven replacement for the old hardcoded TritonMapper.applyState; with
+ * [ScProfile.default] it behaves identically. The [profile] is swappable at runtime (future: action-set
+ * switching). The sink seam makes the whole engine unit-testable headlessly (see ProfileInterpreterTest).
+ */
+class ProfileInterpreter(
+ private val sink: ScOutputSink,
+ @Volatile var profile: ScProfile,
+ private val haptics: TritonHaptics?,
+ /** Time source (ms) for activator timing; override in tests with a virtual clock. */
+ private val clock: () -> Long = { System.currentTimeMillis() },
+ /** Step-6 menu HUD sink; defaults to a no-op so the engine runs headless. */
+ private val menuOverlay: ScMenuOverlay = NoOpScMenuOverlay,
+ /** Split-trackpad keyboard HUD sink; defaults to a no-op. */
+ keyboardOverlay: ScKeyboardOverlay = NoOpScKeyboardOverlay,
+ /** Global "Touchpad deadzone" override (resting-finger freeze radius) for relative-mouse pads; null = use each
+ * [PadMode.Mouse]'s own jitterFloor. Set from [ScTuningStore] by the live driver. */
+ padDeadzone: Int? = null,
+ /** Global "Touchpad smoothing" (0–100) low-pass for pad-mouse motion + the keyboard cursor. 0 = off. */
+ padSmoothing: Int = 0,
+ /** App-UI seam: open the QuickMenu + navigate it (and the in-game editors) from the controller. No-op when
+ * headless / no overlay is attached, so unit tests and the engine-only path are unaffected. */
+ private val uiBridge: ScUiBridge = NoOpScUiBridge,
+) {
+ // Mutable + @Volatile so the live-tune path ([setPadTuning], via TritonMapper.reload after an in-game edit) can
+ // retune feel mid-game from another thread while the input loop reads these per report — no relaunch for dial-in.
+ @Volatile private var padDeadzone: Int? = padDeadzone
+ @Volatile private var padSmoothing: Int = padSmoothing
+
+ /** Split on-screen keyboard; takes over the pads while open (toggled by a [ScOutput.ShowKeyboard] binding). */
+ private val keyboard = ScKeyboard(sink, keyboardOverlay, haptics, clock, padSmoothing)
+
+ /** Live dial-in of the two touchpad-feel knobs (via [TritonMapper.reload] after an in-game edit); also retunes
+ * the keyboard cursor low-pass so one knob still governs both surfaces. */
+ fun setPadTuning(deadzone: Int?, smoothing: Int) {
+ padDeadzone = deadzone
+ padSmoothing = smoothing
+ keyboard.setSmoothing(smoothing)
+ }
+ /** Push the active menu to the overlay HUD; never let a UI error break input. [directional] = a movement radial
+ * whose 8 ring slots show 8-way arrows instead of their bound key; [center] = the radial's center button. */
+ private fun pushMenu(
+ kind: ScMenuSpec.Kind, slots: List, cols: Int, rows: Int, highlighted: Int,
+ center: MenuSlot? = null, directional: Boolean = false,
+ cursorX: Float = Float.NaN, cursorY: Float = Float.NaN, menuId: String = "",
+ ) {
+ // Directional (movement) radials default to 8-way arrows, but a per-slot CUSTOM label always wins (so a
+ // user can override the default arrow/key name). Non-directional menus use the config/key label.
+ val labels = if (directional && slots.size == ARROW8.size) {
+ slots.mapIndexed { i, s -> s.label.ifBlank { ARROW8[i] } }
+ } else slots.map { slotLabel(it) }
+ runCatching {
+ menuOverlay.showMenu(ScMenuSpec(kind, labels, cols, rows, highlighted, center?.let { slotLabel(it) }, cursorX, cursorY, menuId))
+ }
+ }
+
+ /** Overlay text for a menu slot: the config's label, or — when blank (e.g. ToME4's movement radial) — the
+ * bound key/output so the slot still shows what it does. */
+ private fun slotLabel(slot: MenuSlot): String =
+ slot.label.ifBlank { shortOutputName(slot.binding.output) }
+
+ private fun shortOutputName(out: ScOutput?): String = when (out) {
+ is ScOutput.Key -> out.keys.joinToString("+") { shortKeyName(it) }
+ is ScOutput.MouseButton -> when (out.button) {
+ Pointer.Button.BUTTON_LEFT -> "L-Click"
+ Pointer.Button.BUTTON_RIGHT -> "R-Click"
+ Pointer.Button.BUTTON_MIDDLE -> "M-Click"
+ Pointer.Button.BUTTON_SCROLL_UP -> "Scroll↑"
+ Pointer.Button.BUTTON_SCROLL_DOWN -> "Scroll↓"
+ else -> "Mouse"
+ }
+ else -> ""
+ }
+
+ /** Compact key name for the overlay: arrows as glyphs, numpad as "Num N", punctuation as its symbol, else
+ * the bare key name. */
+ private fun shortKeyName(k: XKeycode): String {
+ val n = k.name.removePrefix("KEY_")
+ PUNCT[n]?.let { return it }
+ return when {
+ n == "UP" -> "↑"
+ n == "DOWN" -> "↓"
+ n == "LEFT" -> "←"
+ n == "RIGHT" -> "→"
+ n.startsWith("KP_") -> "Num " + n.removePrefix("KP_")
+ else -> n
+ }
+ }
+
+ private companion object {
+ /** Pad-mouse cursor tuning for menu/editor capture: raw-unit rest deadzone + pad-units→pixels gain. */
+ const val NAV_CURSOR_FLOOR = 80f
+ const val NAV_CURSOR_GAIN = 0.06f
+ const val NAV_CURSOR_TICK_PX = 110f // detent-tick spacing (px of cursor travel); higher = fewer ticks so the click stands out
+ /** Raw gyro rotation-speed reference at which acceleration reaches full [GyroAccel.gain]. Tuned on-device. */
+ const val GYRO_ACCEL_REF = 8000f
+ /** Held-d-pad menu nav auto-repeat: initial delay, then per-step interval (ms). */
+ const val NAV_REPEAT_DELAY_MS = 400L
+ const val NAV_REPEAT_INTERVAL_MS = 130L
+ // Trigger pull (raw 0..32767) past this counts as a held resize intent in the overlay editor (~quarter-pull).
+ const val TRIGGER_NAV_THRESHOLD = 8000
+ /** Macro step timing (ms): how long each command holds, and the gap after it, so the game samples distinct
+ * presses. Tunable by feel on device. [MIN_PULSE_MS] guards a delayed press+release so it still registers. */
+ const val MACRO_STEP_MS = 40L
+ const val MACRO_GAP_MS = 40L
+ const val MIN_PULSE_MS = 20L
+ /** 8-way arrow labels for a directional (movement) radial, clockwise from top — matches the ring order. */
+ val ARROW8 = listOf("↑", "↗", "→", "↘", "↓", "↙", "←", "↖")
+ /** Bare XKeycode name (minus "KEY_") → its glyph, for compact overlay slot labels. */
+ val PUNCT = mapOf(
+ "MINUS" to "-", "EQUAL" to "=", "COMMA" to ",", "PERIOD" to ".", "SLASH" to "/",
+ "BACKSLASH" to "\\", "SEMICOLON" to ";", "APOSTROPHE" to "'", "GRAVE" to "`",
+ "BRACKET_LEFT" to "[", "BRACKET_RIGHT" to "]", "SPACE" to "Space", "ENTER" to "⏎",
+ "TAB" to "Tab", "ESC" to "Esc", "BKSP" to "⌫",
+ )
+ }
+ private fun hideMenu() { runCatching { menuOverlay.hideMenu() } }
+ private val gp = GamepadState()
+ private var prevButtons = 0
+
+ // Held stick position for GyroMode.Joystick(deflection=true): integrated gyro angle, reset when the gate closes.
+ private var gyroDefX = 0f
+ private var gyroDefY = 0f
+ // GyroActivation.TOGGLE state: persistent on/off flipped on each gate press edge.
+ private var gyroToggleOn = false
+ private var gyroTogglePrevOpen = false
+
+ // ---- timed scheduler (per-binding delays + macro playback) ----
+ // A queue of press/release ops due at an absolute clock time. Drained each frame. Keys/mouse go straight to the
+ // sink; gamepad outputs held by a running macro are collected in [macroBtns]/[macroDpad] and OR'd into gp.
+ private class Scheduled(val dueMs: Long, val out: ScOutput, val press: Boolean)
+ private val scheduled = ArrayList()
+ private var macroBtns = 0
+ private val macroDpad = BooleanArray(4)
+
+ private fun drainScheduled(now: Long) {
+ if (scheduled.isNotEmpty()) {
+ val fire = ArrayList()
+ val it = scheduled.iterator()
+ while (it.hasNext()) { val s = it.next(); if (s.dueMs <= now) { fire.add(s); it.remove() } }
+ fire.sortBy { it.dueMs }
+ for (s in fire) when (val o = s.out) {
+ is ScOutput.GamepadButton -> macroBtns = if (s.press) macroBtns or (1 shl o.idx) else macroBtns and (1 shl o.idx).inv()
+ is ScOutput.GamepadDpad -> macroDpad[o.index] = s.press
+ else -> if (s.press) pressOutput(o) else releaseOutput(o)
+ }
+ }
+ // Overlay any macro-held gamepad bits onto this frame's virtual pad (after the level buttons were rebuilt).
+ for (i in 0..15) if (macroBtns and (1 shl i) != 0) gp.setPressed(i, true)
+ for (i in 0..3) if (macroDpad[i]) gp.dpad[i] = true
+ }
+
+ /** Press/release an edge output at an absolute time (immediate if already due). */
+ private fun schedule(out: ScOutput, atMs: Long, press: Boolean, now: Long) {
+ if (atMs <= now) { if (press) pressOutput(out) else releaseOutput(out) } else scheduled.add(Scheduled(atMs, out, press))
+ }
+
+ /** Enqueue a macro (one-shot): each command's outputs are pressed together, held [MACRO_STEP_MS], then released;
+ * commands run in order, framed by their delay_start/delay_end (+ a small gap so steps read as distinct presses). */
+ private fun playMacro(m: ScOutput.Macro, now: Long) {
+ var t = now
+ for (cmd in m.commands) {
+ t += cmd.delayStartMs
+ for (o in cmd.outputs) scheduled.add(Scheduled(t, o, true))
+ val rel = t + MACRO_STEP_MS
+ for (o in cmd.outputs) scheduled.add(Scheduled(rel, o, false))
+ t = rel + cmd.delayEndMs + MACRO_GAP_MS
+ }
+ }
+
+ private fun clearScheduled() {
+ scheduled.clear(); macroBtns = 0; for (i in 0..3) macroDpad[i] = false
+ }
+
+ /** Optional multi-set config. When installed, [ScOutput.SwitchActionSet] bindings swap [profile] live. */
+ @Volatile var config: ScConfig? = null
+ private set
+ /** The Steam preset id of the currently active action set (only meaningful when [config] is installed). */
+ var activeSetId: String? = null
+ private set
+ /** Active action-layer preset ids, base-first; the effective [profile] = active set merged with these. */
+ private val layerStack = ArrayList()
+ /** (button bit, layerId) for `hold_layer` ops: popped when the button is released, tracked by raw bit. */
+ private val heldLayers = ArrayList>()
+ /** (button bit, source, overlay) for momentary `mode_shift` ops: a single-source overlay active while held. */
+ private val heldShifts = ArrayList>()
+
+ /** Install (or clear) a multi-action-set config and jump to its default set (no layers). */
+ fun setConfig(cfg: ScConfig?) {
+ config = cfg
+ layerStack.clear(); heldLayers.clear(); heldShifts.clear()
+ if (cfg != null) {
+ activeSetId = cfg.defaultSetId
+ recompute()
+ }
+ }
+
+ /** Rebuild the effective [profile] = active set with each active layer merged over it, in stack order. */
+ private fun recompute() {
+ val cfg = config ?: return
+ var p = cfg.sets[activeSetId] ?: cfg.defaultProfile()
+ for (layerId in layerStack) {
+ val layer = cfg.sets[layerId] ?: continue
+ p = mergeProfiles(p, layer, cfg.setSources[layerId] ?: emptySet())
+ }
+ for ((_, source, overlay) in heldShifts) { // momentary mode-shifts win on top
+ p = mergeProfiles(p, overlay, setOf(source))
+ }
+ profile = p
+ }
+
+ /** Per-pad runtime state shared by the pad modes (mouse accumulator, active grid cell, scroll accumulator). */
+ private class PadRuntime {
+ var mouseActive = false
+ var lastX = 0
+ var lastY = 0
+ // Sub-pixel remainder for pad-mouse so slow drags aren't lost to integer truncation.
+ var accumX = 0f
+ var accumY = 0f
+ // EMA state for the optional motion low-pass (Touchpad smoothing).
+ var smoothX = 0f
+ var smoothY = 0f
+ var gridCell = -1
+ var scrollAccum = 0
+ var dpadMask = 0
+ // Single-button pad: currently-pressed output (so we can release it). null = not pressed.
+ var singlePressed: ScOutput? = null
+ // Directional-swipe: anchor the flick is measured from (re-set each fresh touch), + last-fired dir gate.
+ var swipeAnchorX = 0
+ var swipeAnchorY = 0
+ var swipeTouched = false
+ /** Radial/Touch menu state for a pad-driven menu. */
+ val menu = MenuRuntime()
+ }
+ private val leftStickMenu = MenuRuntime()
+ private val rightStickMenu = MenuRuntime()
+ private val leftPad = PadRuntime()
+ private val rightPad = PadRuntime()
+
+ // Pad-mouse cursor state while a menu/editor is captured: the RIGHT trackpad drives an on-screen cursor over the
+ // Compose dialog (right-pad click = tap). Trailing-anchor deadzone mirrors [applyPadMouse] to kill rest jitter.
+ private var navCursorActive = false
+ private var navCursorAnchorX = 0
+ private var navCursorAnchorY = 0
+ private var navCursorAccumX = 0f
+ private var navCursorAccumY = 0f
+ private val navClickGain = HapticSettings().clickGain
+ private val navTickGain = HapticSettings().tickGain
+ private var navCursorTickAccum = 0f
+ private var prevCapturing = false
+
+ fun apply(s: TritonState) {
+ // GameNative's QuickMenu / in-game editors take over the controller while up: translate movement + buttons
+ // into Android focus-nav keys and suppress all game output (the BLE Triton isn't an Android input device,
+ // so this is the only way it can drive those Compose surfaces). Checked first so an open menu always wins.
+ val capturing = uiBridge.isMenuCapturing()
+ // On the falling edge (menu/editor closed), remove the pad-mouse nav cursor — nothing else drives it once
+ // capture ends, so without this it freezes on-screen (bug: white dot stuck centre after closing the QuickMenu).
+ if (prevCapturing && !capturing) uiBridge.hideCursor()
+ prevCapturing = capturing
+ if (capturing) {
+ handleMenuNav(s)
+ prevButtons = s.buttons
+ return
+ }
+
+ // On-screen keyboard takes over the controller while open. The toggle binding ([ScOutput.ShowKeyboard])
+ // is honored even in keyboard mode (so the same button closes it). While open, the pads drive the keyboard
+ // and normal mapping/gamepad output is suppressed.
+ handleKeyboardToggle(s)
+ if (keyboard.active) {
+ keyboard.update(s)
+ prevButtons = s.buttons
+ return
+ }
+
+ // Open the QuickMenu on the press edge of an [ScOutput.OpenQuickMenu] binding (default: Steam button). Once
+ // open, the menu-capture branch above handles navigation; freeze the pad neutral and bail this frame.
+ if (handleOpenQuickMenu(s)) {
+ neutralizeGamepad()
+ prevButtons = s.buttons
+ return
+ }
+
+ handleSetSwitch(s) // may swap the active set
+ handleLayerOps(s) // may push/pop action layers; both rebuild the effective `profile`
+ val p = profile
+
+ // ---- level outputs: virtual-pad buttons + d-pad (rebuilt every frame from current state) ----
+ // Reset first so any macro-held gamepad bit (overlaid later in drainScheduled) clears once its macro ends,
+ // rather than sticking (non-mapped bits are never otherwise cleared).
+ gp.buttons = 0
+ for (i in 0..3) gp.dpad[i] = false
+ // Reset the virtual sticks too: every stick writer (JoystickMove / DPad / pad-as-joystick) ASSIGNS each frame,
+ // and gyro-joystick ADDS on top — so without this, gyro-joystick with a None output stick accumulates across
+ // frames instead of tracking rate (camera never returns to center). Deflection keeps its own held accumulator.
+ gp.thumbLX = 0f; gp.thumbLY = 0f; gp.thumbRX = 0f; gp.thumbRY = 0f
+ for ((bit, b) in p.buttons) {
+ when (val out = b.output) {
+ is ScOutput.GamepadButton -> gp.setPressed(out.idx, s.has(bit))
+ is ScOutput.GamepadDpad -> gp.dpad[out.index] = s.has(bit)
+ else -> {} // edge outputs handled below
+ }
+ }
+ applyStick(p.leftStick, s.leftStickX, s.leftStickY, s.has(TritonProtocol.BTN_L3), s.has(TritonProtocol.BTN_LSTICK_TOUCH), leftStickMenu, ScMenuLocation.LEFT_STICK.name)
+ applyStick(p.rightStick, s.rightStickX, s.rightStickY, s.has(TritonProtocol.BTN_R3), s.has(TritonProtocol.BTN_RSTICK_TOUCH), rightStickMenu, ScMenuLocation.RIGHT_STICK.name)
+ applyTrigger(p.leftTrigger, s.triggerLeft, leftTrig)
+ applyTrigger(p.rightTrigger, s.triggerRight, rightTrig)
+ // A gyro-to-joystick mode adds to the stick, so it must land in this frame's gp (before the push below).
+ applyGyro(p.gyro, s)
+
+ // ---- edge outputs (mouse buttons + keys) through their activators ----
+ val now = clock()
+ for ((bit, b) in p.buttons) {
+ when (b.output) {
+ // Edge outputs route through activators. (Per-binding delay/toggle on a gamepad LEVEL button is a
+ // separate code path, deferred to a later wave — see the buttons loop above.)
+ is ScOutput.MouseButton, is ScOutput.Key, is ScOutput.MouseNudge, is ScOutput.MousePosition, is ScOutput.Macro -> applyActivator(bit, b, s, now)
+ else -> {}
+ }
+ }
+
+ // ---- trackpads (mode-driven: mouse / grid / d-pad / scroll / pad-as-joystick) ----
+ applyPad(p.leftPad, leftPad, s, TritonProtocol.BTN_LPAD_TOUCH, TritonProtocol.BTN_LPAD_CLICK, s.leftPadX, s.leftPadY, ScMenuLocation.LEFT_PAD.name)
+ applyPad(p.rightPad, rightPad, s, TritonProtocol.BTN_RPAD_TOUCH, TritonProtocol.BTN_RPAD_CLICK, s.rightPadX, s.rightPadY, ScMenuLocation.RIGHT_PAD.name)
+
+ // Fire any due scheduled ops (per-binding delays + macro steps) and overlay macro-held gamepad bits onto gp.
+ drainScheduled(now)
+
+ // Push the virtual pad AFTER every gp contributor (buttons, sticks, triggers, gyro, pad-as-joystick, macros).
+ sink.gamepad(gp)
+
+
+ // ---- regenerate trackpad haptics (profile-driven feel) ----
+ haptics?.update(s, prevButtons, p.haptics)
+
+ prevButtons = s.buttons
+ }
+
+ private fun rising(s: TritonState, bit: Int) = (s.buttons and bit) != 0 && (prevButtons and bit) == 0
+ private fun falling(s: TritonState, bit: Int) = (s.buttons and bit) == 0 && (prevButtons and bit) != 0
+
+ /** Toggle the on-screen keyboard on the press edge of any [ScOutput.ShowKeyboard] binding (honored even while
+ * the keyboard is open, so the same button closes it). On open, the virtual pad is frozen at neutral. */
+ private fun handleKeyboardToggle(s: TritonState) {
+ var hasBinding = false
+ for ((bit, b) in profile.buttons) {
+ if (b.output is ScOutput.ShowKeyboard) {
+ hasBinding = true
+ if (rising(s, bit)) { toggleKeyboard(); return }
+ }
+ }
+ // Global fallback: if the active profile binds the keyboard nowhere AND the "..." Quick-Access (3-dots)
+ // button is otherwise unbound, let it toggle the keyboard — so per-game .vdf configs that omit
+ // SHOW_KEYBOARD (e.g. ToME4) still get the on-screen keyboard.
+ if (!hasBinding && !profile.buttons.containsKey(TritonProtocol.BTN_QAM) && rising(s, TritonProtocol.BTN_QAM)) {
+ toggleKeyboard()
+ }
+ }
+
+ private fun toggleKeyboard() {
+ if (keyboard.active) {
+ keyboard.deactivate()
+ } else {
+ keyboard.activate()
+ neutralizeGamepad()
+ }
+ }
+
+ /** Freeze the virtual pad at neutral (used when an overlay takes over the controller, so the game sees no
+ * stuck input while the keyboard / QuickMenu is up). */
+ private fun neutralizeGamepad() {
+ clearScheduled() // drop any in-flight macro / delayed press so nothing stays stuck while an overlay is up
+ gp.thumbLX = 0f; gp.thumbLY = 0f; gp.thumbRX = 0f; gp.thumbRY = 0f
+ gp.triggerL = 0f; gp.triggerR = 0f; gp.buttons = 0
+ for (i in 0..3) gp.dpad[i] = false
+ sink.gamepad(gp)
+ }
+
+ /** Open the QuickMenu on the press edge of an [ScOutput.OpenQuickMenu] binding (default: Steam button). As a
+ * global fallback, an otherwise-unbound Steam button opens it too (so .vdf configs still reach the menu).
+ * Returns true if the menu was opened this frame. */
+ private fun handleOpenQuickMenu(s: TritonState): Boolean {
+ var hasBinding = false
+ for ((bit, b) in profile.buttons) {
+ if (b.output is ScOutput.OpenQuickMenu) {
+ hasBinding = true
+ if (rising(s, bit)) { uiBridge.openQuickMenu(); return true }
+ }
+ }
+ if (!hasBinding && !profile.buttons.containsKey(TritonProtocol.BTN_STEAM) && rising(s, TritonProtocol.BTN_STEAM)) {
+ uiBridge.openQuickMenu(); return true
+ }
+ return false
+ }
+
+ /** While a GameNative menu/editor is captured, translate the controller into Android focus-nav keys: d-pad and
+ * left-stick (edge-triggered into a direction) move focus, A selects, B / Steam go back. Never touches the
+ * game output. */
+ private fun handleMenuNav(s: TritonState) {
+ val now = clock()
+ // The currently-held nav direction comes from the d-pad bits, else the left stick deflection. Holding a
+ // direction auto-repeats (initial [NAV_REPEAT_DELAY_MS], then every [NAV_REPEAT_INTERVAL_MS]) so the user can
+ // hold to scroll a long list instead of tapping per item.
+ val dir: ScNavKey? = when {
+ (s.buttons and TritonProtocol.BTN_DPAD_UP) != 0 -> ScNavKey.UP
+ (s.buttons and TritonProtocol.BTN_DPAD_DOWN) != 0 -> ScNavKey.DOWN
+ (s.buttons and TritonProtocol.BTN_DPAD_LEFT) != 0 -> ScNavKey.LEFT
+ (s.buttons and TritonProtocol.BTN_DPAD_RIGHT) != 0 -> ScNavKey.RIGHT
+ else -> when (stickNavDir(s.leftStickX, s.leftStickY)) {
+ 1 -> ScNavKey.UP; 2 -> ScNavKey.DOWN; 3 -> ScNavKey.LEFT; 4 -> ScNavKey.RIGHT; else -> null
+ }
+ }
+ when {
+ dir == null -> navHeldDir = null
+ dir != navHeldDir -> { navHeldDir = dir; uiBridge.nav(dir); navRepeatAt = now + NAV_REPEAT_DELAY_MS }
+ now >= navRepeatAt -> { uiBridge.nav(dir); navRepeatAt = now + NAV_REPEAT_INTERVAL_MS }
+ }
+
+ // Triggers = resize intent in the overlay placement editor (RT bigger, LT smaller); ignored by other menus.
+ // Own held-repeat so the user can resize while the stick moves the overlay. Threshold ~quarter-pull.
+ val zoom: ScNavKey? = when {
+ s.triggerRight > TRIGGER_NAV_THRESHOLD -> ScNavKey.ZOOM_IN
+ s.triggerLeft > TRIGGER_NAV_THRESHOLD -> ScNavKey.ZOOM_OUT
+ else -> null
+ }
+ when {
+ zoom == null -> zoomHeld = null
+ zoom != zoomHeld -> { zoomHeld = zoom; uiBridge.nav(zoom); zoomRepeatAt = now + NAV_REPEAT_DELAY_MS }
+ now >= zoomRepeatAt -> { uiBridge.nav(zoom); zoomRepeatAt = now + NAV_REPEAT_INTERVAL_MS }
+ }
+
+ // Edge-triggered nav (A=Select, B/Steam=Back, LB/RB=tab-or-set, Y=Help) is defined ONCE in the fixed
+ // [ScMenuNav] table so the interpreter and the editor tooltips can never drift apart.
+ for (c in ScMenuNav.controls) if (rising(s, c.buttonBit)) uiBridge.nav(c.key)
+
+ // Right trackpad = pad-mouse cursor over the dialog (the additive Steam-Controller nav option). Runs alongside
+ // d-pad nav so either works; complex editor screens are usable by pointing + clicking instead of tab-focusing.
+ handleMenuCursor(s)
+ }
+
+ private fun handleMenuCursor(s: TritonState) {
+ val touched = (s.buttons and TritonProtocol.BTN_RPAD_TOUCH) != 0
+ if (!touched) {
+ navCursorActive = false
+ } else if (!navCursorActive) {
+ navCursorActive = true
+ navCursorAnchorX = s.rightPadX; navCursorAnchorY = s.rightPadY
+ navCursorAccumX = 0f; navCursorAccumY = 0f
+ } else {
+ val dxRaw = (s.rightPadX - navCursorAnchorX).toFloat()
+ val dyRaw = (s.rightPadY - navCursorAnchorY).toFloat()
+ val dist = hypot(dxRaw, dyRaw)
+ if (dist >= NAV_CURSOR_FLOOR) {
+ val ux = dxRaw / dist; val uy = dyRaw / dist
+ navCursorAnchorX = (s.rightPadX - ux * NAV_CURSOR_FLOOR).roundToInt()
+ navCursorAnchorY = (s.rightPadY - uy * NAV_CURSOR_FLOOR).roundToInt()
+ val over = dist - NAV_CURSOR_FLOOR
+ navCursorAccumX += ux * over * NAV_CURSOR_GAIN
+ navCursorAccumY += -uy * over * NAV_CURSOR_GAIN // pad +Y is up; screen +Y is down
+ val dx = navCursorAccumX.toInt(); val dy = navCursorAccumY.toInt()
+ if (dx != 0 || dy != 0) {
+ uiBridge.moveCursor(dx, dy)
+ navCursorAccumX -= dx; navCursorAccumY -= dy
+ // Detent ticks as the cursor travels (reuse the keyboard's per-cell tick) so dragging feels like a
+ // textured surface, not a dead glide — one tick every [NAV_CURSOR_TICK_PX] pixels of travel.
+ navCursorTickAccum += hypot(dx.toFloat(), dy.toFloat())
+ if (navCursorTickAccum >= NAV_CURSOR_TICK_PX) {
+ navCursorTickAccum %= NAV_CURSOR_TICK_PX
+ haptics?.tick(1, navTickGain)
+ }
+ }
+ }
+ }
+ if (rising(s, TritonProtocol.BTN_RPAD_CLICK)) {
+ uiBridge.cursorTap()
+ haptics?.click(1, navClickGain) // right pad (side 1) — a real "click" feel so you can tell it registered
+ }
+ }
+
+ /** Dominant left-stick direction past a deadzone: 0=none, 1=up, 2=down, 3=left, 4=right. Stick axes are s16
+ * (±32767); Y is up-positive on the SC, so up = +Y. */
+ private fun stickNavDir(x: Int, y: Int): Int {
+ val t = 16000
+ if (abs(x) < t && abs(y) < t) return 0
+ return if (abs(y) >= abs(x)) { if (y > 0) 1 else 2 } else { if (x < 0) 3 else 4 }
+ }
+ private var navHeldDir: ScNavKey? = null
+ private var navRepeatAt = 0L
+ private var zoomHeld: ScNavKey? = null
+ private var zoomRepeatAt = 0L
+
+ /**
+ * Config-driven action-set switching: a [ScOutput.SwitchActionSet] binding swaps the active [profile] when
+ * its button hits the matching edge (press, or release for the menu-set's "return" binding). The "hold for
+ * menus" feel is encoded by the config itself (set A enters on press, set B leaves on release), so this stays
+ * stateless. No-op unless an [ScConfig] is installed.
+ */
+ private fun handleSetSwitch(s: TritonState) {
+ val cfg = config ?: return
+ for ((bit, b) in profile.buttons) {
+ val out = b.output
+ if (out is ScOutput.SwitchActionSet) {
+ val fire = if (out.onRelease) falling(s, bit) else rising(s, bit)
+ if (fire && cfg.sets.containsKey(out.targetSetId)) {
+ activeSetId = out.targetSetId
+ layerStack.clear(); heldLayers.clear(); heldShifts.clear() // new set: no layers/shifts
+ recompute()
+ runCatching { menuOverlay.toast(cfg.sets[activeSetId]?.name ?: "Set $activeSetId") }
+ }
+ }
+ }
+ }
+
+ /**
+ * Config-driven action **layers**: `add_layer`/`hold_layer`/`remove_layer` bindings ([ScOutput.LayerOp])
+ * push/pop a partial overlay ([mergeProfiles]) over the active set. `hold_layer` is popped on the button's
+ * release — tracked by raw bit so it survives the layer rebinding that button. No-op without an [ScConfig].
+ */
+ private fun handleLayerOps(s: TritonState) {
+ val cfg = config ?: return
+ var changed = false
+ // Pop held layers / mode-shifts whose button was released.
+ val held = heldLayers.iterator()
+ while (held.hasNext()) {
+ val (bit, layerId) = held.next()
+ if (falling(s, bit)) {
+ layerStack.remove(layerId); held.remove(); changed = true
+ // Releasing a hold-layer returns to the base set — pop the OSD title like action-set switching.
+ runCatching { menuOverlay.toast(cfg.sets[activeSetId]?.name ?: "Set $activeSetId") }
+ }
+ }
+ val shifts = heldShifts.iterator()
+ while (shifts.hasNext()) {
+ if (falling(s, shifts.next().first)) { shifts.remove(); changed = true }
+ }
+ // Apply press-edge ops from the current effective profile.
+ for ((bit, b) in profile.buttons) {
+ when (val out = b.output) {
+ is ScOutput.LayerOp -> if (rising(s, bit) && cfg.sets.containsKey(out.layerId)) {
+ // OSD pop-up mirrors action-set switching: show the layer's name on push, the base set on remove.
+ fun toastLayer() = runCatching { menuOverlay.toast(cfg.sets[out.layerId]?.name ?: "Layer ${out.layerId}") }
+ when (out.op) {
+ LayerOpType.ADD -> if (!layerStack.contains(out.layerId)) { layerStack.add(out.layerId); changed = true; toastLayer() }
+ LayerOpType.REMOVE -> if (layerStack.remove(out.layerId)) {
+ changed = true; runCatching { menuOverlay.toast(cfg.sets[activeSetId]?.name ?: "Set $activeSetId") }
+ }
+ LayerOpType.HOLD -> if (!layerStack.contains(out.layerId)) {
+ layerStack.add(out.layerId); heldLayers.add(bit to out.layerId); changed = true; toastLayer()
+ }
+ }
+ }
+ is ScOutput.ModeShift -> if (rising(s, bit) && heldShifts.none { it.first == bit }) {
+ cfg.shiftOverlays[out.groupId]?.let { heldShifts.add(Triple(bit, out.source, it)); changed = true }
+ }
+ else -> {}
+ }
+ }
+ if (changed) recompute()
+ }
+
+ private class ActState {
+ var lastRise = 0L // DoublePress: time of the first press
+ var awaitSecond = false // DoublePress: a first press is pending a possible second
+ var downTime = 0L // LongPress: time the press began
+ var fired = false // LongPress: threshold reached and output held
+ var lastFire = 0L // Turbo: time of the last pulse
+ var held = false // Regular/LongPress: output currently held down
+ var toggled = false // Toggle: output currently latched on
+ var pressDueMs = 0L // Regular+delay: when the deferred press is/was due (so a delayed release stays after it)
+ }
+ private val actStates = HashMap()
+
+ /** Drive an edge output (Key / MouseButton) through its activator's press logic. */
+ private fun applyActivator(bit: Int, b: Binding, s: TritonState, now: Long) {
+ val out = b.output
+ val pressed = s.has(bit)
+ val rose = rising(s, bit)
+ val fell = falling(s, bit)
+ val st = actStates.getOrPut(bit) { ActState() }
+ // Macro: play the whole sequence once on the press edge (one-shot; hold/release don't affect it).
+ if (out is ScOutput.Macro) { if (rose) playMacro(out, now); return }
+ // Toggle: the press edge latches the output on, the next press latches it off (delays apply to each edge).
+ if (b.toggle) {
+ if (rose) { st.toggled = !st.toggled; schedule(out, now + (if (st.toggled) b.delayStartMs else b.delayEndMs), st.toggled, now) }
+ return
+ }
+ when (val act = b.activator) {
+ Activator.Regular -> {
+ // Fire Start/End Delay: defer the press/release. "Fire anyway" — a press scheduled on the rise happens
+ // even if released during the delay; the release is clamped to stay after the press so a tap registers.
+ // With no delay set, both fire immediately (identical to the pre-delay path).
+ val delayed = b.delayStartMs > 0 || b.delayEndMs > 0
+ if (rose) { st.pressDueMs = now + b.delayStartMs; schedule(out, st.pressDueMs, true, now); st.held = true }
+ if (fell) { schedule(out, if (delayed) maxOf(now + b.delayEndMs, st.pressDueMs + MIN_PULSE_MS) else now, false, now); st.held = false }
+ }
+ is Activator.DoublePress -> {
+ if (rose) {
+ if (st.awaitSecond && now - st.lastRise <= act.windowMs) {
+ pulse(out); st.awaitSecond = false
+ } else {
+ st.lastRise = now; st.awaitSecond = true
+ }
+ }
+ }
+ is Activator.LongPress -> {
+ if (rose) { st.downTime = now; st.fired = false }
+ if (pressed && !st.fired && now - st.downTime >= act.holdMs) {
+ pressOutput(out); st.held = true; st.fired = true
+ }
+ if (fell) { if (st.held) { releaseOutput(out); st.held = false }; st.fired = false }
+ }
+ is Activator.Turbo -> {
+ if (rose) { pulse(out); st.lastFire = now }
+ else if (pressed && now - st.lastFire >= act.intervalMs) { pulse(out); st.lastFire = now }
+ }
+ Activator.OnRelease -> {
+ if (fell) pulse(out) // fire a quick pulse when the button is let go
+ }
+ }
+ }
+
+ private fun pulse(out: ScOutput) { pressOutput(out); releaseOutput(out) }
+
+ private fun applyStick(mode: StickMode, rawX: Int, rawY: Int, clicked: Boolean, touched: Boolean, menuRt: MenuRuntime, menuId: String) {
+ when (mode) {
+ is StickMode.RadialMenu -> driveMenu(
+ stickMag(rawX, rawY) >= mode.deadzone, touched, clicked, rawX, rawY,
+ mode.slots, ScMenuSpec.Kind.RADIAL, 0, 0, mode.activation, commitOnClick = false, menuRt,
+ center = mode.center, directional = mode.directional, menuId = menuId,
+ )
+ is StickMode.TouchMenu -> driveMenu(
+ stickMag(rawX, rawY) >= mode.deadzone, touched, clicked, rawX, rawY,
+ mode.slots, ScMenuSpec.Kind.GRID, mode.cols, mode.rows, mode.activation, commitOnClick = false, menuRt,
+ menuId = menuId,
+ )
+ is StickMode.JoystickMove -> {
+ val x = axisCurved(rawX, mode.deadzone, mode.curve)
+ val y = axisCurved(if (mode.invertY) -rawY else rawY, mode.deadzone, mode.curve)
+ if (mode.stick == Stick.LEFT) {
+ gp.thumbLX = x; gp.thumbLY = y
+ } else {
+ gp.thumbRX = x; gp.thumbRY = y
+ }
+ }
+ is StickMode.Mouse -> {
+ val x = axisCurved(rawX, mode.deadzone, mode.curve)
+ val y = axisCurved(if (mode.invertY) -rawY else rawY, mode.deadzone, mode.curve)
+ val dx = (x * mode.sensitivity).toInt()
+ val dy = (-y * mode.sensitivity).toInt() // screen Y grows downward
+ if (dx != 0 || dy != 0) sink.mouseMove(dx, dy)
+ }
+ is StickMode.FlickStick -> {
+ // Simplified: horizontal deflection -> yaw mouse velocity (NOT the true flick-and-rotate model).
+ val x = axis(rawX, mode.deadzone)
+ val dx = (x * mode.sensitivity).toInt()
+ if (dx != 0) sink.mouseMove(dx, 0)
+ }
+ is StickMode.DPad -> applyStickDpad(mode, rawX, rawY, menuRt)
+ StickMode.None -> {}
+ }
+ }
+
+ private class TrigRuntime { var soft = false; var full = false }
+ private val leftTrig = TrigRuntime()
+ private val rightTrig = TrigRuntime()
+
+ private fun applyTrigger(mode: TriggerMode, raw: Int, rt: TrigRuntime) {
+ when (mode) {
+ is TriggerMode.Axis -> setTriggerAxis(mode.axis, raw)
+ is TriggerMode.Staged -> {
+ setTriggerAxis(mode.axis, raw)
+ val v = (raw / 32767f).coerceIn(0f, 1f)
+ val soft = v >= mode.softThreshold
+ val full = v >= mode.fullThreshold
+ if (soft && !rt.soft) pressOutput(mode.soft)
+ if (!soft && rt.soft) releaseOutput(mode.soft)
+ rt.soft = soft
+ if (full && !rt.full) pressOutput(mode.full)
+ if (!full && rt.full) releaseOutput(mode.full)
+ rt.full = full
+ }
+ }
+ }
+
+ private fun setTriggerAxis(axis: TriggerAxis, raw: Int) {
+ val v = (raw / 32767f).coerceIn(0f, 1f)
+ when (axis) {
+ TriggerAxis.GAMEPAD_L2 -> gp.triggerL = v
+ TriggerAxis.GAMEPAD_R2 -> gp.triggerR = v
+ TriggerAxis.NONE -> {}
+ }
+ }
+
+ private fun applyPad(mode: PadMode, rt: PadRuntime, s: TritonState, touchBit: Int, clickBit: Int, x: Int, y: Int, menuId: String) {
+ when (mode) {
+ PadMode.None -> {
+ rt.mouseActive = false; releaseGridCell(mode, rt)
+ if (rt.menu.active) hideMenu()
+ rt.menu.slot = -1; rt.menu.active = false; rt.menu.heldSlot = -1
+ if (rt.singlePressed != null) { releaseOutput(rt.singlePressed); rt.singlePressed = null }
+ rt.swipeTouched = false
+ }
+ is PadMode.Mouse -> applyPadMouse(mode, rt, s.has(touchBit), x, y)
+ is PadMode.AbsoluteMouse -> applyPadAbsolute(mode, s.has(touchBit), x, y)
+ is PadMode.SingleButton -> applyPadSingle(mode, rt, s.has(if (mode.onClick) clickBit else touchBit))
+ is PadMode.DirectionalSwipe -> applyPadSwipe(mode, rt, s.has(touchBit), x, y)
+ is PadMode.ButtonPadGrid -> applyPadGrid(mode, rt, s.has(if (mode.onClick) clickBit else touchBit), x, y)
+ is PadMode.DPad -> applyPadDpad(mode, rt, s.has(touchBit), x, y)
+ is PadMode.ScrollWheel -> applyPadScroll(mode, rt, s.has(touchBit), y)
+ is PadMode.Joystick -> applyPadJoystick(mode, s.has(touchBit), x, y)
+ is PadMode.MouseJoystick -> applyPadMouseJoystick(mode, s.has(touchBit), x, y)
+ is PadMode.RadialMenu -> driveMenu(s.has(touchBit), s.has(touchBit), s.has(clickBit), x, y, mode.slots, ScMenuSpec.Kind.RADIAL, 0, 0, mode.activation, mode.onClick, rt.menu, center = mode.center, directional = mode.directional, menuId = menuId)
+ is PadMode.TouchMenu -> driveMenu(s.has(touchBit), s.has(touchBit), s.has(clickBit), x, y, mode.slots, ScMenuSpec.Kind.GRID, mode.cols, mode.rows, mode.activation, mode.onClick, rt.menu, menuId = menuId)
+ }
+ }
+
+ // ---- shared Radial/Touch menu engine (pad- or stick-driven) ----
+ private class MenuRuntime { var slot = -1; var active = false; var shown = false; var heldSlot = -1; var lastFire = 0L; var committed = false; var dpadMask = 0 }
+
+ /**
+ * Drive a Radial/Touch menu from any 2D source. [active] = the source is engaged (pad touched / stick deflected
+ * past its deadzone). While active the highlighted slot tracks the position; firing depends on [activation]:
+ * COMMIT = pulse once on click ([commitOnClick]) or on disengage (point-and-release); HOLD = the slot's output
+ * is held while pointed and released when the highlight changes or the source disengages. Pushes the HUD while
+ * active, hides it on disengage.
+ */
+ private fun driveMenu(
+ active: Boolean, shown: Boolean, clicked: Boolean, x: Int, y: Int,
+ slots: List, kind: ScMenuSpec.Kind, cols: Int, rows: Int,
+ activation: MenuActivation, commitOnClick: Boolean, rt: MenuRuntime,
+ center: MenuSlot? = null, directional: Boolean = false, menuId: String = "",
+ ) {
+ if (slots.isEmpty()) { rt.active = active; rt.shown = shown; return }
+ val now = clock()
+ // Cursor dot position for the radial HUD (normalized source position; pads/sticks both report ±32768).
+ val curX = if (kind == ScMenuSpec.Kind.RADIAL) (x / 32768f).coerceIn(-1f, 1f) else Float.NaN
+ val curY = if (kind == ScMenuSpec.Kind.RADIAL) (y / 32768f).coerceIn(-1f, 1f) else Float.NaN
+ if (active) {
+ if (!rt.active) rt.committed = false // fresh engagement
+ rt.slot = when (kind) {
+ ScMenuSpec.Kind.RADIAL -> radialSlot(x, y, slots.size, rt.slot)
+ ScMenuSpec.Kind.GRID -> gridSlot(x, y, cols, rows, slots.size)
+ }
+ when (activation) {
+ MenuActivation.HOLD -> {
+ // A movement-radial slot with hold_repeats (Activator.Turbo) should *repeat* the key while the
+ // stick stays pointed (re-pulse every intervalMs, matching Steam's repeat_rate) — some games
+ // (ToME4) only repeat on discrete presses, not a held key. Slots without it hold continuously.
+ val turbo = slots.getOrNull(rt.slot)?.binding?.activator as? Activator.Turbo
+ if (rt.heldSlot != rt.slot) {
+ releaseHeldIfContinuous(slots, rt.heldSlot) // turbo slots already pulsed (key is up)
+ rt.heldSlot = rt.slot
+ if (turbo != null) { pulseSlot(slots, rt.slot); rt.lastFire = now } else pressOutput(slots.getOrNull(rt.slot)?.binding?.output)
+ } else if (turbo != null && now - rt.lastFire >= turbo.intervalMs) {
+ pulseSlot(slots, rt.slot); rt.lastFire = now
+ }
+ }
+ MenuActivation.COMMIT -> {
+ // Per-slot Turbo (hold_repeats): re-fire while the slot stays pointed, instead of a single
+ // commit. Non-turbo slots commit immediately on a pad click — even in release-style menus
+ // (requires_click=0), clicking is always a valid commit (matches Steam) — else on
+ // point-and-release at disengage (below). [committed] gates the release so click+release
+ // doesn't double-fire.
+ val turbo = slots.getOrNull(rt.slot)?.binding?.activator as? Activator.Turbo
+ if (turbo != null) {
+ if (rt.heldSlot != rt.slot) { rt.heldSlot = rt.slot; pulseSlot(slots, rt.slot); rt.lastFire = now }
+ else if (now - rt.lastFire >= turbo.intervalMs) { pulseSlot(slots, rt.slot); rt.lastFire = now }
+ } else if (clicked && !rt.committed) {
+ pulseSlot(slots, rt.slot); rt.committed = true
+ }
+ }
+ }
+ pushMenu(kind, slots, cols, rows, rt.slot, center, directional, curX, curY, menuId)
+ } else {
+ // Not engaged (centered / finger lifted). If we WERE engaged, finalize the just-ended deflection.
+ if (rt.active) {
+ when (activation) {
+ MenuActivation.HOLD -> { releaseHeldIfContinuous(slots, rt.heldSlot); rt.heldSlot = -1 }
+ MenuActivation.COMMIT -> {
+ // point-and-release commit — for release-style menus only, and not if a click already
+ // committed this engagement or it's a Turbo slot (which fired repeatedly while held).
+ val wasTurbo = slots.getOrNull(rt.slot)?.binding?.activator is Activator.Turbo
+ if (!commitOnClick && !wasTurbo && !rt.committed) pulseSlot(slots, rt.slot)
+ }
+ }
+ rt.slot = -1; rt.heldSlot = -1; rt.committed = false
+ }
+ // HUD: keep it visible while the source is still *touched* (thumb resting on the stick / finger on the
+ // pad) even before deflecting — show the ring with nothing highlighted; hide once the source is released.
+ if (shown) { rt.slot = -1; pushMenu(kind, slots, cols, rows, -1, center, directional, curX, curY, menuId) }
+ else if (rt.shown || rt.active) hideMenu()
+ }
+ rt.active = active
+ rt.shown = shown
+ }
+
+ /** Radial angle -> slot (0 at top, clockwise); keeps [prev] while inside the center dead-zone (~0.25 radius). */
+ private fun radialSlot(x: Int, y: Int, n: Int, prev: Int): Int {
+ val nx = x / 32768f
+ val ny = y / 32768f
+ if (nx * nx + ny * ny < 0.0625f) return prev
+ val ang = Math.toDegrees(kotlin.math.atan2(nx.toDouble(), ny.toDouble())) // 0=up, +clockwise
+ val norm = ((ang % 360) + 360) % 360
+ return Math.round(norm / (360.0 / n)).toInt() % n
+ }
+
+ /** Grid cell -> slot (row 0 = top), or -1 if past the slot count. */
+ private fun gridSlot(x: Int, y: Int, cols: Int, rows: Int, n: Int): Int {
+ val nx = (x.toFloat() / 65536f + 0.5f).coerceIn(0f, 0.999f)
+ val ny = (y.toFloat() / 65536f + 0.5f).coerceIn(0f, 0.999f)
+ val col = (nx * cols).toInt().coerceIn(0, cols - 1)
+ val rowTop = (rows - 1) - (ny * rows).toInt().coerceIn(0, rows - 1)
+ val cell = rowTop * cols + col
+ return if (cell < n) cell else -1
+ }
+
+ private fun pulseSlot(slots: List, slot: Int) {
+ val out = slots.getOrNull(slot)?.binding?.output ?: return
+ pressOutput(out); releaseOutput(out)
+ }
+
+ /** Release a HOLD slot's key only if it was held continuously (non-Turbo). Turbo slots are pulsed, so their
+ * key is already up and a release would emit a spurious key-up. */
+ private fun releaseHeldIfContinuous(slots: List, idx: Int) {
+ val b = slots.getOrNull(idx)?.binding ?: return
+ if (b.activator !is Activator.Turbo) releaseOutput(b.output)
+ }
+
+ /** d-pad direction mask for a normalized deflection, honoring the [layout] (bit0=up, bit1=down, bit2=left,
+ * bit3=right; +cy = up). See [DpadLayout]. */
+ private fun dpadMask(cx: Float, cy: Float, deadzone: Float, layout: DpadLayout, overlap: Float): Int {
+ val ax = abs(cx); val ay = abs(cy)
+ if (ax < deadzone && ay < deadzone) return 0
+ return when (layout) {
+ DpadLayout.EIGHT_WAY, DpadLayout.ANALOG_EMU -> {
+ var m = 0
+ if (cy > deadzone) m = m or 0x1
+ if (cy < -deadzone) m = m or 0x2
+ if (cx < -deadzone) m = m or 0x4
+ if (cx > deadzone) m = m or 0x8
+ m
+ }
+ DpadLayout.FOUR_WAY, DpadLayout.CROSS_GATE -> {
+ // Cross gate: near-diagonals (the two axes close in magnitude) press nothing.
+ if (layout == DpadLayout.CROSS_GATE && abs(ay - ax) < overlap) return 0
+ if (ay >= ax) { if (cy > 0) 0x1 else 0x2 } else { if (cx < 0) 0x4 else 0x8 }
+ }
+ }
+ }
+
+ /** d-pad: edge-press [outs] as the mask changes; returns the new mask (store it back). [cx]/[cy] = normalized
+ * deflection (+Y up); [active]=false forces neutral (e.g. pad lifted). */
+ private fun driveDpad(cx: Float, cy: Float, active: Boolean, deadzone: Float, layout: DpadLayout, overlap: Float, outs: Array, prevMask: Int): Int {
+ val mask = if (!active) 0 else dpadMask(cx, cy, deadzone, layout, overlap)
+ if (mask == prevMask) return mask
+ for (i in 0..3) {
+ val bit = 1 shl i
+ if (mask and bit != 0 && prevMask and bit == 0) pressOutput(outs[i])
+ if (mask and bit == 0 && prevMask and bit != 0) releaseOutput(outs[i])
+ }
+ return mask
+ }
+
+ private fun applyPadDpad(mode: PadMode.DPad, rt: PadRuntime, touched: Boolean, x: Int, y: Int) {
+ rt.dpadMask = driveDpad(x / 32768f, y / 32768f, touched, mode.deadzone, mode.layout, mode.overlap,
+ arrayOf(mode.up, mode.down, mode.left, mode.right), rt.dpadMask)
+ }
+
+ /** Stick as a d-pad: always live (deflection past the deadzone presses a direction; recenters to neutral). */
+ private fun applyStickDpad(mode: StickMode.DPad, rawX: Int, rawY: Int, rt: MenuRuntime) {
+ rt.dpadMask = driveDpad(rawX / 32768f, rawY / 32768f, active = true, mode.deadzone, mode.layout, mode.overlap,
+ arrayOf(mode.up, mode.down, mode.left, mode.right), rt.dpadMask)
+ }
+
+ /** Scroll wheel: accumulate vertical travel; each [step] units emits one wheel click. */
+ private fun applyPadScroll(mode: PadMode.ScrollWheel, rt: PadRuntime, touched: Boolean, y: Int) {
+ if (!touched) { rt.mouseActive = false; return }
+ if (!rt.mouseActive) { rt.mouseActive = true; rt.lastY = y; rt.scrollAccum = 0; return }
+ rt.scrollAccum += (y - rt.lastY)
+ rt.lastY = y
+ val up = if (mode.invertY) Pointer.Button.BUTTON_SCROLL_DOWN else Pointer.Button.BUTTON_SCROLL_UP
+ val down = if (mode.invertY) Pointer.Button.BUTTON_SCROLL_UP else Pointer.Button.BUTTON_SCROLL_DOWN
+ while (rt.scrollAccum >= mode.step) { clickWheel(up); rt.scrollAccum -= mode.step } // finger up = scroll up
+ while (rt.scrollAccum <= -mode.step) { clickWheel(down); rt.scrollAccum += mode.step }
+ }
+
+ private fun clickWheel(button: Pointer.Button) {
+ sink.mouseButton(button, true)
+ sink.mouseButton(button, false)
+ }
+
+ /** Absolute / region mouse: warp the cursor to where the finger is (mapped 1:1 onto the mode's screen region)
+ * while the pad is touched. No deadzone/accumulation — it's a direct position, not a relative delta. */
+ private fun applyPadAbsolute(mode: PadMode.AbsoluteMouse, touched: Boolean, x: Int, y: Int) {
+ if (!touched) return
+ val px = (x / 65536f + 0.5f).coerceIn(0f, 1f) // 0 = left, 1 = right
+ val pyUp = (y / 65536f + 0.5f).coerceIn(0f, 1f) // 0 = bottom, 1 = top (pad reports +Y up)
+ val fx = if (mode.invertX) 1f - px else px
+ val fyTop = if (mode.invertY) pyUp else 1f - pyUp // 0 = screen top
+ // Region-relative offset from center (−0.5..0.5), optionally rotated (Rotate Output), then scaled onto the region.
+ var ox = fx - 0.5f
+ var oy = fyTop - 0.5f
+ if (mode.rotation != 0f) {
+ val r = Math.toRadians(mode.rotation.toDouble())
+ val c = kotlin.math.cos(r).toFloat(); val sn = kotlin.math.sin(r).toFloat()
+ val rx = ox * c - oy * sn; val ry = ox * sn + oy * c
+ ox = rx; oy = ry
+ }
+ // Map the pad extent onto the region centered at (centerX,centerY) spanning sizeX/sizeY of the screen.
+ val nx = mode.centerX + ox * mode.sizeX
+ val ny = mode.centerY + oy * mode.sizeY
+ sink.mouseMoveAbs(nx.coerceIn(0f, 1f), ny.coerceIn(0f, 1f))
+ }
+
+ /** Pad-as-joystick: the finger's absolute position = stick deflection while touched; recenters (zero) on lift.
+ * Same curve/deadzone math as a physical stick ([applyStick]'s JoystickMove branch). */
+ private fun applyPadJoystick(mode: PadMode.Joystick, touched: Boolean, x: Int, y: Int) {
+ val vx = if (touched) axisCurved(x, mode.deadzone, mode.curve) else 0f
+ val vy = if (touched) axisCurved(if (mode.invertY) -y else y, mode.deadzone, mode.curve) else 0f
+ if (mode.stick == Stick.LEFT) { gp.thumbLX = vx; gp.thumbLY = vy } else { gp.thumbRX = vx; gp.thumbRY = vy }
+ }
+
+ /** Mouse Joystick: the finger's displacement from the pad CENTER drives a cursor velocity (self-centering, like
+ * a joystick that outputs mouse). Zero inside the deadzone; keeps moving while held off-centre; stops on lift. */
+ private fun applyPadMouseJoystick(mode: PadMode.MouseJoystick, touched: Boolean, x: Int, y: Int) {
+ if (!touched) return
+ val nx = (x / 32768f).coerceIn(-1f, 1f)
+ val ny = (y / 32768f).coerceIn(-1f, 1f) // pad +Y up
+ if (hypot(nx, ny) < mode.deadzone) return
+ val dx = (nx * mode.sensitivity).toInt()
+ val dy = ((if (mode.invertY) ny else -ny) * mode.sensitivity).toInt() // screen Y grows downward
+ if (dx != 0 || dy != 0) sink.mouseMove(dx, dy)
+ }
+
+ /** Single-button pad: whole surface = one button, pressed while touched/clicked, released on lift. */
+ private fun applyPadSingle(mode: PadMode.SingleButton, rt: PadRuntime, engaged: Boolean) {
+ if (engaged && rt.singlePressed == null) { rt.singlePressed = mode.output; pressOutput(mode.output) }
+ else if (!engaged && rt.singlePressed != null) { releaseOutput(rt.singlePressed); rt.singlePressed = null }
+ }
+
+ /** Directional swipe: a flick past the threshold from the touch anchor pulses that cardinal direction's output,
+ * then slides the anchor so a continued swipe keeps firing (scroll-like). Re-anchors on each fresh touch. */
+ private fun applyPadSwipe(mode: PadMode.DirectionalSwipe, rt: PadRuntime, touched: Boolean, x: Int, y: Int) {
+ if (!touched) { rt.swipeTouched = false; return }
+ if (!rt.swipeTouched) { rt.swipeTouched = true; rt.swipeAnchorX = x; rt.swipeAnchorY = y; return }
+ val dx = x - rt.swipeAnchorX
+ val dy = y - rt.swipeAnchorY
+ val horiz = mode.scrollMode != SwipeAxes.VERTICAL
+ val vert = mode.scrollMode != SwipeAxes.HORIZONTAL
+ if (horiz && kotlin.math.abs(dx) >= mode.threshold && kotlin.math.abs(dx) >= kotlin.math.abs(dy)) {
+ pulseOutput(if (dx > 0) mode.right else mode.left); rt.swipeAnchorX = x; rt.swipeAnchorY = y
+ } else if (vert && kotlin.math.abs(dy) >= mode.threshold) {
+ pulseOutput(if (dy > 0) mode.up else mode.down); rt.swipeAnchorX = x; rt.swipeAnchorY = y // pad +Y up
+ }
+ }
+
+ /** Fire an edge output as a one-shot press+release pulse (for swipe / one-shot commands). */
+ private fun pulseOutput(out: ScOutput?) { if (out != null && out != ScOutput.None) { pressOutput(out); releaseOutput(out) } }
+
+ private fun applyPadMouse(mode: PadMode.Mouse, rt: PadRuntime, touched: Boolean, x: Int, y: Int) {
+ if (!touched) { rt.mouseActive = false; return }
+ if (!rt.mouseActive) {
+ rt.mouseActive = true; rt.lastX = x; rt.lastY = y; rt.accumX = 0f; rt.accumY = 0f; rt.smoothX = 0f; rt.smoothY = 0f
+ return
+ }
+ // Trailing-anchor deadzone (a "rubber band"): [lastX]/[lastY] is the anchor, not the previous report. While
+ // the finger stays within [floor] raw units of the anchor it's treated as resting → no motion (this kills
+ // resting jitter that a per-report gate leaks through, since a noise spike measured from a *fixed* anchor
+ // stays small). Once the finger moves past the floor, emit only the OVERSHOOT (distance beyond the
+ // deadzone) and slide the anchor to trail the finger by [floor] — so sustained movement tracks ~1:1 while a
+ // brief spike leaks only a pixel or two. The global "Touchpad deadzone" ([padDeadzone]) sets the radius.
+ val dxRaw = (x - rt.lastX).toFloat()
+ val dyRaw = (y - rt.lastY).toFloat() // raw pad space (+Y up); screen-Y handled at emit
+ val floor = mode.jitterFloor.toFloat() // per-pad (was globally overridden by ScTuningStore deadzone)
+ val dist = hypot(dxRaw, dyRaw)
+ if (dist < floor) return
+ val ux = dxRaw / dist
+ val uy = dyRaw / dist
+ rt.lastX = (x - ux * floor).roundToInt() // anchor trails the finger by the deadzone radius
+ rt.lastY = (y - uy * floor).roundToInt()
+ val over = dist - floor
+ var rawMoveX = ux * over * mode.sensitivity * mode.horizScale
+ var rawMoveY = (if (mode.invertY) -uy else uy) * over * mode.sensitivity * mode.vertScale
+ if (mode.rotation != 0f) { // Rotate Output: spin the pointer-delta vector.
+ val r = Math.toRadians(mode.rotation.toDouble())
+ val c = kotlin.math.cos(r).toFloat(); val sn = kotlin.math.sin(r).toFloat()
+ val rx = rawMoveX * c - rawMoveY * sn; val ry = rawMoveX * sn + rawMoveY * c
+ rawMoveX = rx; rawMoveY = ry
+ }
+ // Optional low-pass (per-pad smoothing): EMA the motion to damp frame-to-frame jitter (costs a little lag).
+ val a = ScTuningStore.emaAlpha(mode.smoothing)
+ rt.smoothX = rt.smoothX * (1f - a) + rawMoveX * a
+ rt.smoothY = rt.smoothY * (1f - a) + rawMoveY * a
+ // Accumulate scaled motion so sub-pixel movement isn't truncated away; emit the integer part, keep remainder.
+ rt.accumX += rt.smoothX
+ rt.accumY += rt.smoothY
+ val dx = rt.accumX.toInt()
+ val dy = rt.accumY.toInt()
+ if (dx != 0 || dy != 0) {
+ sink.mouseMove(dx, dy)
+ rt.accumX -= dx; rt.accumY -= dy
+ }
+ }
+
+ /** Button Pad / ToME grid: the cell under the finger is active while the activating bit is set. */
+ private fun applyPadGrid(mode: PadMode.ButtonPadGrid, rt: PadRuntime, active: Boolean, x: Int, y: Int) {
+ if (!active) {
+ if (rt.gridCell >= 0) { releaseOutput(mode.cells.getOrNull(rt.gridCell)); rt.gridCell = -1 }
+ return
+ }
+ // pad position -> normalized 0..1 (nx left->right, ny bottom->top, since pad +Y is up)
+ val nx = (x.toFloat() / 65536f + 0.5f).coerceIn(0f, 0.999f)
+ val ny = (y.toFloat() / 65536f + 0.5f).coerceIn(0f, 0.999f)
+ val col = (nx * mode.cols).toInt().coerceIn(0, mode.cols - 1)
+ val row = (ny * mode.rows).toInt().coerceIn(0, mode.rows - 1)
+ val cell = row * mode.cols + col
+ if (cell != rt.gridCell) {
+ releaseOutput(mode.cells.getOrNull(rt.gridCell))
+ rt.gridCell = cell
+ pressOutput(mode.cells.getOrNull(cell))
+ }
+ }
+
+ private fun releaseGridCell(mode: PadMode, rt: PadRuntime) {
+ if (rt.gridCell >= 0 && mode is PadMode.ButtonPadGrid) releaseOutput(mode.cells.getOrNull(rt.gridCell))
+ rt.gridCell = -1
+ }
+
+ /** Press an edge-style output (keys / mouse buttons). Gamepad/dpad/none cells are not edge-pressable. */
+ private fun pressOutput(out: ScOutput?) {
+ when (out) {
+ is ScOutput.Key -> out.keys.forEach { sink.key(it, true) }
+ is ScOutput.MouseButton -> sink.mouseButton(out.button, true)
+ is ScOutput.MouseNudge -> if (out.dx != 0 || out.dy != 0) sink.mouseMove(out.dx, out.dy) // one-shot move
+ is ScOutput.MousePosition -> sink.mouseMoveAbs(out.nx, out.ny) // one-shot absolute warp
+ else -> {}
+ }
+ }
+
+ private fun releaseOutput(out: ScOutput?) {
+ when (out) {
+ is ScOutput.Key -> out.keys.asReversed().forEach { sink.key(it, false) }
+ is ScOutput.MouseButton -> sink.mouseButton(out.button, false)
+ else -> {}
+ }
+ }
+
+ private fun gyroGateOpen(gate: GyroGate, s: TritonState): Boolean = when (gate) {
+ GyroGate.ALWAYS -> true
+ GyroGate.LEFT_GRIP -> s.has(TritonProtocol.BTN_LGRIP)
+ GyroGate.RIGHT_GRIP -> s.has(TritonProtocol.BTN_RGRIP)
+ GyroGate.EITHER_GRIP -> s.has(TritonProtocol.BTN_LGRIP) || s.has(TritonProtocol.BTN_RGRIP)
+ GyroGate.LEFT_PAD_TOUCH -> s.has(TritonProtocol.BTN_LPAD_TOUCH)
+ GyroGate.RIGHT_PAD_TOUCH -> s.has(TritonProtocol.BTN_RPAD_TOUCH)
+ GyroGate.LEFT_STICK_TOUCH -> s.has(TritonProtocol.BTN_LSTICK_TOUCH)
+ GyroGate.RIGHT_STICK_TOUCH -> s.has(TritonProtocol.BTN_RSTICK_TOUCH)
+ GyroGate.ANY_TOUCH -> s.has(TritonProtocol.BTN_LPAD_TOUCH) || s.has(TritonProtocol.BTN_RPAD_TOUCH) ||
+ s.has(TritonProtocol.BTN_LSTICK_TOUCH) || s.has(TritonProtocol.BTN_RSTICK_TOUCH)
+ GyroGate.ALL_TOUCH -> s.has(TritonProtocol.BTN_LPAD_TOUCH) && s.has(TritonProtocol.BTN_RPAD_TOUCH) &&
+ s.has(TritonProtocol.BTN_LSTICK_TOUCH) && s.has(TritonProtocol.BTN_RSTICK_TOUCH)
+ GyroGate.L4 -> s.has(TritonProtocol.BTN_L4)
+ GyroGate.L5 -> s.has(TritonProtocol.BTN_L5)
+ GyroGate.R4 -> s.has(TritonProtocol.BTN_R4)
+ GyroGate.R5 -> s.has(TritonProtocol.BTN_R5)
+ GyroGate.LEFT_BUMPER -> s.has(TritonProtocol.BTN_LBUMPER)
+ GyroGate.RIGHT_BUMPER -> s.has(TritonProtocol.BTN_RBUMPER)
+ GyroGate.A -> s.has(TritonProtocol.BTN_A)
+ GyroGate.B -> s.has(TritonProtocol.BTN_B)
+ GyroGate.X -> s.has(TritonProtocol.BTN_X)
+ GyroGate.Y -> s.has(TritonProtocol.BTN_Y)
+ GyroGate.L3 -> s.has(TritonProtocol.BTN_L3)
+ GyroGate.R3 -> s.has(TritonProtocol.BTN_R3)
+ }
+
+ /** Whether the gyro is aiming this frame, combining the [gate] input with its [activation] behavior:
+ * ENABLE = while held, SUPPRESS = while NOT held, TOGGLE = flip on each press edge. */
+ private fun gyroActive(gate: GyroGate, activation: GyroActivation, s: TritonState): Boolean {
+ val open = gyroGateOpen(gate, s)
+ return when (activation) {
+ GyroActivation.ENABLE -> open
+ // ALWAYS has no gate button, so "suppress" has nothing to suppress with → gyro just stays on.
+ GyroActivation.SUPPRESS -> gate == GyroGate.ALWAYS || !open
+ GyroActivation.TOGGLE -> {
+ if (open && !gyroTogglePrevOpen) gyroToggleOn = !gyroToggleOn // flip on the press edge
+ gyroTogglePrevOpen = open
+ gyroToggleOn
+ }
+ }
+ }
+
+ /** Shape a raw joystick axis value in [-1,1]: power curve on the magnitude (0.1 aggressive … 1 linear … 4 relaxed),
+ * then rescale into [GyroMode.Joystick.outputMin]..[outputMax]. Sign preserved. */
+ private fun shapeJoy(v: Float, mode: GyroMode.Joystick): Float {
+ if (v == 0f) return 0f
+ var t = abs(v).coerceIn(0f, 1f)
+ if (mode.powerCurve != 1f) t = Math.pow(t.toDouble(), mode.powerCurve.toDouble()).toFloat()
+ val out = mode.outputMin + t * (mode.outputMax - mode.outputMin)
+ return if (v < 0) -out else out
+ }
+
+ private fun applyGyro(mode: GyroMode, s: TritonState) {
+ when (mode) {
+ is GyroMode.Mouse -> {
+ if (gyroActive(mode.gate, mode.activation, s)) {
+ // Natural default: yaw-right → aim-right, pitch-up → aim-up (negate the raw sign, which felt
+ // backwards on device). invertX/invertY flip each axis back.
+ val sx = if (mode.invertX) 1f else -1f
+ val sy = if (mode.invertY) 1f else -1f
+ val gz = s.gyroZ.toFloat(); val gx = s.gyroX.toFloat()
+ val speed = hypot(gx, gz) // rotation speed (raw units)
+ if (mode.speedDeadzone <= 0f || speed >= mode.speedDeadzone) { // below speed deadzone → no output
+ var sens = mode.sensitivity
+ // Precision: below precisionSpeed, scale sensitivity down proportionally (fine aim).
+ if (mode.precisionSpeed > 0f && speed < mode.precisionSpeed) sens *= speed / mode.precisionSpeed
+ // Acceleration: scale sensitivity UP with speed (fast flicks turn further).
+ if (mode.accel.gain > 0f) sens *= 1f + mode.accel.gain * (speed / GYRO_ACCEL_REF).coerceIn(0f, 1f)
+ // H/V mixer: >0 reduces horizontal, <0 reduces vertical (0 = 1:1).
+ val hScale = if (mode.hvMixer > 0f) 1f - mode.hvMixer else 1f
+ val vScale = if (mode.hvMixer < 0f) 1f + mode.hvMixer else 1f
+ val dx = (sx * gz * sens * hScale).toInt()
+ val dy = (sy * gx * sens * vScale).toInt()
+ if (dx != 0 || dy != 0) sink.mouseMove(dx, dy)
+ }
+ }
+ }
+ is GyroMode.Joystick -> {
+ // ADDED on top of any physical stick when gated open (so gyro layers onto stick-look rather than
+ // fighting it); no-op when gated closed. Camera = rate→deflection (returns to center at rest);
+ // deflection = integrated angle→held position (stays put until you rotate back).
+ val open = gyroActive(mode.gate, mode.activation, s)
+ val sx = if (mode.invertX) 1f else -1f
+ val sy = if (mode.invertY) 1f else -1f
+ val x: Float; val y: Float
+ if (mode.deflection) {
+ // Integrate rate while gated; reset the accumulated angle to center when released (ratchet, so
+ // gyro drift can't accumulate). ponytail: raw-count integration, no dt — sensitivity is the
+ // per-game feel knob (matches camera's frame-rate model); switch to dt if frame rate drifts.
+ if (open) {
+ gyroDefX = (gyroDefX + sx * s.gyroZ * mode.sensitivity).coerceIn(-1f, 1f)
+ gyroDefY = (gyroDefY + sy * s.gyroX * mode.sensitivity).coerceIn(-1f, 1f)
+ } else { gyroDefX = 0f; gyroDefY = 0f }
+ x = gyroDefX; y = gyroDefY
+ } else {
+ if (!open) return
+ x = (sx * s.gyroZ * mode.sensitivity).coerceIn(-1f, 1f)
+ y = (sy * s.gyroX * mode.sensitivity).coerceIn(-1f, 1f)
+ }
+ // Output shaping: power curve + output range per axis, then optional lock-at-edges magnitude clamp.
+ var ox = shapeJoy(x, mode); var oy = shapeJoy(y, mode)
+ if (mode.lockAtEdges) {
+ val mag = hypot(ox, oy)
+ if (mag > mode.outputMax && mag > 0f) { val k = mode.outputMax / mag; ox *= k; oy *= k }
+ }
+ if (mode.stick == Stick.LEFT) {
+ gp.thumbLX = (gp.thumbLX + ox).coerceIn(-1f, 1f); gp.thumbLY = (gp.thumbLY + oy).coerceIn(-1f, 1f)
+ } else {
+ gp.thumbRX = (gp.thumbRX + ox).coerceIn(-1f, 1f); gp.thumbRY = (gp.thumbRY + oy).coerceIn(-1f, 1f)
+ }
+ }
+ GyroMode.None -> {}
+ }
+ }
+
+ private fun axis(raw: Int, deadzone: Float): Float {
+ val v = (raw / 32767f).coerceIn(-1f, 1f)
+ return if (abs(v) < deadzone) 0f else v
+ }
+
+ /** Stick deflection magnitude 0..~1 (for menu activation thresholds). */
+ private fun stickMag(x: Int, y: Int): Float {
+ val nx = x / 32768f; val ny = y / 32768f
+ return kotlin.math.sqrt(nx * nx + ny * ny)
+ }
+
+ /**
+ * Deadzoned axis with a [curve] applied to the magnitude beyond the deadzone (sign preserved). LINEAR is
+ * intentionally identical to [axis] (no rescale) so the golden-trace regression stays valid; non-linear
+ * curves rescale the post-deadzone range to 0..1 before shaping.
+ */
+ private fun axisCurved(raw: Int, deadzone: Float, curve: ResponseCurve): Float {
+ val v = (raw / 32767f).coerceIn(-1f, 1f)
+ val a = abs(v)
+ if (a < deadzone) return 0f
+ if (curve == ResponseCurve.LINEAR) return v
+ val scaled = ((a - deadzone) / (1f - deadzone)).coerceIn(0f, 1f)
+ val shaped = curve.apply(scaled).coerceIn(0f, 1f)
+ return if (v < 0) -shaped else shaped
+ }
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/ScConfigStore.kt b/app/src/main/java/app/gamenative/steamcontroller/ScConfigStore.kt
new file mode 100644
index 0000000000..ab5cf731c8
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/ScConfigStore.kt
@@ -0,0 +1,427 @@
+package app.gamenative.steamcontroller
+
+import android.content.Context
+import android.util.Log
+import app.gamenative.utils.SteamControllerProfileImporter
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import java.io.File
+
+/** What a saved config is sourced from. [VDF] = an imported Steam config (raw `.vdf` text, parsed at load);
+ * [AUTHORED] = built with the in-game editor (an [ScEditableConfig]). */
+enum class ScConfigKind { VDF, AUTHORED }
+
+/** One saved config in a game's registry: a stable [id] (used in file names), a user-facing [name], its [kind]. */
+@Serializable
+data class ScConfigEntry(val id: String, val name: String, val kind: ScConfigKind)
+
+/** A game's saved-config registry: the ordered [configs] list + which one is [activeId] (loaded on boot). */
+@Serializable
+data class ScConfigRegistry(val activeId: String = "", val configs: List = emptyList()) {
+ fun active(): ScConfigEntry? = configs.firstOrNull { it.id == activeId } ?: configs.firstOrNull()
+ fun nextId(): String = generateSequence(1) { it + 1 }.first { n -> configs.none { it.id == "c$n" } }.let { "c$it" }
+}
+
+/**
+ * Per-game Steam Controller config store. Each game (keyed by container/appId, or [DEFAULT_KEY] for the shared
+ * default) owns a **registry of named configs** plus a selected *active* config that the live [TritonMapper] loads
+ * on boot. A config is either an imported `.vdf` ([ScConfigKind.VDF], parsed by [SteamControllerProfileImporter])
+ * or one authored in-game ([ScConfigKind.AUTHORED], an [ScEditableConfig]). Users switch the active config and
+ * duplicate configs from the Controller settings; editing saves a config in place (see [saveEditableConfig]).
+ *
+ * Storage layout (under `filesDir/sc_configs/`):
+ * - `.configs.json` — the [ScConfigRegistry] manifest
+ * - `__.vdf` — a VDF config's raw text
+ * - `__.sets.json`— an AUTHORED config's [ScEditableConfig]
+ * - `.labels.json` — custom menu-slot labels (per game, layered over whichever config is active)
+ *
+ * Legacy single-config files (`.vdf` / `.sets.json` / `.json`) are migrated into a registry on
+ * first access (see [registry]); the migration preserves the previous resolution (a `.vdf` stays active if present)
+ * so an upgrade doesn't change behavior — the user opts into a different config via the selector.
+ */
+object ScConfigStore {
+ private const val TAG = "ScConfigStore"
+ private const val DIR = "sc_configs"
+
+ /** Key for the shared config applied to any game without its own registry. */
+ const val DEFAULT_KEY = "_default"
+
+ private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
+
+ private fun dir(context: Context): File = File(context.filesDir, DIR).apply { mkdirs() }
+
+ /** File names are sanitized so an arbitrary container id/name can't escape the store dir. */
+ private fun sanitize(key: String): String = key.ifBlank { DEFAULT_KEY }.replace(Regex("[^A-Za-z0-9._-]"), "_")
+
+ private fun manifestFile(context: Context, key: String) = File(dir(context), "${sanitize(key)}.configs.json")
+ private fun vdfPayload(context: Context, key: String, id: String) = File(dir(context), "${sanitize(key)}__$id.vdf")
+ private fun setsPayload(context: Context, key: String, id: String) = File(dir(context), "${sanitize(key)}__$id.sets.json")
+ /** A VDF config's editable **overlay** (lossless edits applied over the parsed `.vdf` base). */
+ private fun overlayPayload(context: Context, key: String, id: String) = File(dir(context), "${sanitize(key)}__$id.overlay.json")
+ private fun labelsFile(context: Context, key: String): File = File(dir(context), "${sanitize(key)}.labels.json")
+
+ // Legacy single-config paths (pre-registry); read only during migration.
+ private fun legacyVdf(context: Context, key: String) = File(dir(context), "${sanitize(key)}.vdf")
+ private fun legacySets(context: Context, key: String) = File(dir(context), "${sanitize(key)}.sets.json")
+ private fun legacyJson(context: Context, key: String) = File(dir(context), "${sanitize(key)}.json")
+
+ // --- Registry --------------------------------------------------------------------------------------
+
+ /** Load (or migrate-then-load) the saved-config registry for [key]. Never null; empty when nothing exists. */
+ fun registry(context: Context, key: String): ScConfigRegistry {
+ manifestFile(context, key).takeIf { it.isFile }?.let { f ->
+ runCatching { json.decodeFromString(ScConfigRegistry.serializer(), f.readText()) }
+ .onFailure { Log.w(TAG, "registry($key) parse failed: ${it.message}") }
+ .getOrNull()?.let { return it }
+ }
+ return migrateLegacy(context, key)
+ }
+
+ // Write to a temp file then rename over the manifest, so a crash/kill mid-write can't leave a truncated
+ // .configs.json (live autosave/reload rewrites this often).
+ private fun writeRegistry(context: Context, key: String, reg: ScConfigRegistry): Boolean =
+ runCatching {
+ val target = manifestFile(context, key)
+ // Unique temp name so concurrent writes (autosave + a UI action) can't clobber each other's temp.
+ val tmp = File.createTempFile("${target.name}-", ".tmp", target.parentFile)
+ tmp.writeText(json.encodeToString(ScConfigRegistry.serializer(), reg))
+ if (!tmp.renameTo(target)) { target.writeText(tmp.readText()); tmp.delete() }
+ true
+ }
+ .onFailure { Log.w(TAG, "writeRegistry($key) failed: ${it.message}") }
+ .getOrDefault(false)
+
+ /** Build a registry from any legacy single-config files, moving them to namespaced payloads, and persist it.
+ * Active = the imported `.vdf` if present (preserves prior resolution), else the authored config. */
+ private fun migrateLegacy(context: Context, key: String): ScConfigRegistry {
+ val entries = ArrayList()
+ var active = ""
+ legacyVdf(context, key).takeIf { it.isFile }?.let { f ->
+ val id = "vdf"
+ runCatching { f.copyTo(vdfPayload(context, key, id), overwrite = true); f.delete() }
+ .onFailure { Log.w(TAG, "migrate vdf($key) failed: ${it.message}") }
+ entries += ScConfigEntry(id, "Imported", ScConfigKind.VDF)
+ active = id
+ }
+ // Authored: prefer the multi-set `.sets.json`, else convert a legacy single `.json`.
+ val sets = legacySets(context, key)
+ val single = legacyJson(context, key)
+ if (sets.isFile) {
+ val id = "authored"
+ val name = runCatching { json.decodeFromString(ScEditableConfig.serializer(), sets.readText()) }
+ .getOrNull()?.let { it.sets.firstOrNull()?.name } ?: "Custom bindings"
+ runCatching { sets.copyTo(setsPayload(context, key, id), overwrite = true); sets.delete() }
+ .onFailure { Log.w(TAG, "migrate sets($key) failed: ${it.message}") }
+ entries += ScConfigEntry(id, name.ifBlank { "Custom bindings" }, ScConfigKind.AUTHORED)
+ if (active.isEmpty()) active = id
+ } else if (single.isFile) {
+ val id = "authored"
+ val profile = runCatching { json.decodeFromString(ScEditableProfile.serializer(), single.readText()) }.getOrNull()
+ if (profile != null) {
+ val cfg = ScEditableConfig.fromSingle(profile)
+ runCatching { setsPayload(context, key, id).writeText(json.encodeToString(ScEditableConfig.serializer(), cfg)) }
+ .onFailure { Log.w(TAG, "migrate json($key) failed: ${it.message}") }
+ single.delete()
+ entries += ScConfigEntry(id, profile.name.ifBlank { "Custom bindings" }, ScConfigKind.AUTHORED)
+ if (active.isEmpty()) active = id
+ }
+ }
+ val reg = ScConfigRegistry(active, entries)
+ if (entries.isNotEmpty()) {
+ writeRegistry(context, key, reg)
+ Log.i(TAG, "migrated $key -> registry(active=$active, configs=${entries.map { it.id to it.kind }})")
+ }
+ return reg
+ }
+
+ /** The saved configs for [key], in order (empty when none exist). */
+ fun listConfigs(context: Context, key: String): List = registry(context, key).configs
+
+ /** The active config id for [key], or null when the game has no configs. */
+ fun activeConfigId(context: Context, key: String): String? = registry(context, key).active()?.id
+
+ /** Select [id] as the active (boot-loaded) config for [key]. Returns true if it exists and was set. */
+ fun setActiveConfig(context: Context, key: String, id: String): Boolean {
+ val reg = registry(context, key)
+ if (reg.configs.none { it.id == id }) return false
+ return writeRegistry(context, key, reg.copy(activeId = id))
+ }
+
+ /** Rename config [id]. Returns true on success. */
+ fun renameConfig(context: Context, key: String, id: String, newName: String): Boolean {
+ val reg = registry(context, key)
+ if (reg.configs.none { it.id == id }) return false
+ val updated = reg.copy(configs = reg.configs.map { if (it.id == id) it.copy(name = newName.ifBlank { it.name }) else it })
+ return writeRegistry(context, key, updated)
+ }
+
+ /** Duplicate config [id] to a new config named [newName] (which becomes active). Returns the new id, or null. */
+ fun duplicateConfig(context: Context, key: String, id: String, newName: String): String? {
+ val reg = registry(context, key)
+ val src = reg.configs.firstOrNull { it.id == id } ?: return null
+ val newId = reg.nextId()
+ val ok = when (src.kind) {
+ ScConfigKind.VDF -> runCatching {
+ vdfPayload(context, key, id).copyTo(vdfPayload(context, key, newId), overwrite = true)
+ // Carry the edit overlay too, so a duplicate of an edited vdf keeps those edits.
+ overlayPayload(context, key, id).takeIf { it.isFile }?.copyTo(overlayPayload(context, key, newId), overwrite = true)
+ true
+ }.getOrDefault(false)
+ ScConfigKind.AUTHORED -> runCatching { setsPayload(context, key, id).copyTo(setsPayload(context, key, newId), overwrite = true); true }.getOrDefault(false)
+ }
+ if (!ok) { Log.w(TAG, "duplicateConfig($key,$id) copy failed"); return null }
+ val updated = reg.copy(
+ configs = reg.configs + ScConfigEntry(newId, newName.ifBlank { "${src.name} copy" }, src.kind),
+ activeId = newId,
+ )
+ return if (writeRegistry(context, key, updated)) newId else null
+ }
+
+ /** Delete config [id] (and its payload). Active falls back to the first remaining config. Returns true if removed. */
+ fun deleteConfig(context: Context, key: String, id: String): Boolean {
+ val reg = registry(context, key)
+ val entry = reg.configs.firstOrNull { it.id == id } ?: return false
+ when (entry.kind) {
+ ScConfigKind.VDF -> { vdfPayload(context, key, id).delete(); overlayPayload(context, key, id).delete() }
+ ScConfigKind.AUTHORED -> setsPayload(context, key, id).delete()
+ }
+ val remaining = reg.configs.filter { it.id != id }
+ val newActive = if (reg.activeId == id) (remaining.firstOrNull()?.id ?: "") else reg.activeId
+ return writeRegistry(context, key, reg.copy(configs = remaining, activeId = newActive))
+ }
+
+ /** Import raw `.vdf` [text] for [key] as a new VDF config named [name], made active. Returns the new id, or null. */
+ fun importVdfConfig(context: Context, key: String, text: String, name: String = "Imported"): String? {
+ val reg = registry(context, key)
+ val newId = reg.nextId()
+ if (!runCatching { vdfPayload(context, key, newId).writeText(text); true }.getOrDefault(false)) {
+ Log.w(TAG, "importVdfConfig($key) write failed"); return null
+ }
+ val updated = reg.copy(
+ configs = reg.configs + ScConfigEntry(newId, name, ScConfigKind.VDF),
+ activeId = newId,
+ )
+ return if (writeRegistry(context, key, updated)) newId else null
+ }
+
+ // --- Authored-config IO (editor) -------------------------------------------------------------------
+
+ /**
+ * Load the authored [ScEditableConfig] the editor should open for [key]: the active config if it is AUTHORED;
+ * otherwise (active is a `.vdf`, or no config) the resolved config seeded into the editable model so the editor
+ * still shows the current bindings. Null only when nothing resolves at all.
+ */
+ fun loadEditableConfig(context: Context, key: String): ScEditableConfig? {
+ val active = registry(context, key).active()
+ when (active?.kind) {
+ ScConfigKind.AUTHORED -> setsPayload(context, key, active.id).takeIf { it.isFile }?.let { f ->
+ runCatching { json.decodeFromString(ScEditableConfig.serializer(), f.readText()) }
+ .onFailure { Log.w(TAG, "loadEditableConfig($key) failed: ${it.message}") }
+ .getOrNull()?.let { return it }
+ }
+ // A .vdf-active config edits via an overlay: load the prior overlay if any, else seed from THIS game's
+ // parsed vdf so the editor shows its action sets/bindings (advanced outputs seed as INHERIT = preserved).
+ ScConfigKind.VDF -> {
+ overlayPayload(context, key, active.id).takeIf { it.isFile }?.let { f ->
+ runCatching { json.decodeFromString(ScEditableConfig.serializer(), f.readText()) }
+ .onFailure { Log.w(TAG, "loadEditableConfig($key) overlay failed: ${it.message}") }
+ .getOrNull()?.let { return it }
+ }
+ vdfPayload(context, key, active.id).takeIf { it.isFile }?.let { parseVdf(it) }
+ ?.let { return ScEditableConfig.fromScConfig(it) }
+ }
+ null -> {}
+ }
+ // No own config: seed the editor from the resolved (shared-default) config.
+ return resolveConfig(context, key)?.let { ScEditableConfig.fromScConfig(it) }
+ }
+
+ /**
+ * Save the edited [cfg] for [key], in place:
+ * - active is AUTHORED → overwrite its `.sets.json`.
+ * - active is a `.vdf` → write an **overlay** (`.overlay.json`) beside the untouched base vdf. Resolution
+ * ([resolveActive]) parses the vdf as the base and applies the overlay on top, so menus/radials/layers/
+ * mode-shift the editor didn't touch are preserved exactly (lossless edit — no fork to a default-based copy).
+ * - no active config → create a new AUTHORED config and make it active.
+ * Returns true on success.
+ */
+ fun saveEditableConfig(context: Context, key: String, cfg: ScEditableConfig): Boolean {
+ val reg = registry(context, key)
+ val active = reg.active()
+ val text = runCatching { json.encodeToString(ScEditableConfig.serializer(), cfg) }.getOrNull() ?: return false
+ when (active?.kind) {
+ ScConfigKind.AUTHORED -> return runCatching { setsPayload(context, key, active.id).writeText(text); true }
+ .onFailure { Log.w(TAG, "saveEditableConfig($key) failed: ${it.message}") }
+ .getOrDefault(false)
+ ScConfigKind.VDF -> return runCatching { overlayPayload(context, key, active.id).writeText(text); true }
+ .onFailure { Log.w(TAG, "saveEditableConfig($key) overlay failed: ${it.message}") }
+ .getOrDefault(false)
+ null -> {}
+ }
+ // No active config yet: create a fresh authored config from the edit.
+ val newId = reg.nextId()
+ if (!runCatching { setsPayload(context, key, newId).writeText(text); true }.getOrDefault(false)) return false
+ return writeRegistry(context, key, reg.copy(
+ configs = reg.configs + ScConfigEntry(newId, "Custom bindings", ScConfigKind.AUTHORED),
+ activeId = newId,
+ ))
+ }
+
+ // --- Compatibility shims (debug / tests / older callers) -------------------------------------------
+
+ /** Path of the active VDF config's payload (or the conventional namespaced path) — debug logging only. */
+ fun fileFor(context: Context, key: String): File {
+ val active = registry(context, key).active()
+ return if (active?.kind == ScConfigKind.VDF) vdfPayload(context, key, active.id) else vdfPayload(context, key, "vdf")
+ }
+
+ /** True if [key] has at least one imported `.vdf` config. */
+ fun hasConfig(context: Context, key: String): Boolean = registry(context, key).configs.any { it.kind == ScConfigKind.VDF }
+
+ /** True if [key] has at least one authored config. */
+ fun hasEditable(context: Context, key: String): Boolean = registry(context, key).configs.any { it.kind == ScConfigKind.AUTHORED }
+
+ /** Import raw `.vdf` [text] for [key] (debug/back-compat): replaces an existing VDF config if one is active. */
+ fun saveVdf(context: Context, key: String, vdfText: String): Boolean {
+ val active = registry(context, key).active()
+ if (active?.kind == ScConfigKind.VDF) {
+ return runCatching { vdfPayload(context, key, active.id).writeText(vdfText); true }.getOrDefault(false)
+ }
+ return importVdfConfig(context, key, vdfText) != null
+ }
+
+ /** Remove all imported `.vdf` configs for [key]. Returns true if any were removed. */
+ fun removeConfig(context: Context, key: String): Boolean = removeByKind(context, key, ScConfigKind.VDF)
+
+ /** Remove all authored configs for [key]. Returns true if any were removed. */
+ fun removeEditable(context: Context, key: String): Boolean = removeByKind(context, key, ScConfigKind.AUTHORED)
+
+ private fun removeByKind(context: Context, key: String, kind: ScConfigKind): Boolean {
+ val reg = registry(context, key)
+ val toRemove = reg.configs.filter { it.kind == kind }
+ if (toRemove.isEmpty()) return false
+ toRemove.forEach { e ->
+ when (e.kind) {
+ ScConfigKind.VDF -> { vdfPayload(context, key, e.id).delete(); overlayPayload(context, key, e.id).delete() }
+ ScConfigKind.AUTHORED -> setsPayload(context, key, e.id).delete()
+ }
+ }
+ val remaining = reg.configs.filter { it.kind != kind }
+ val newActive = if (remaining.any { it.id == reg.activeId }) reg.activeId else (remaining.firstOrNull()?.id ?: "")
+ return writeRegistry(context, key, reg.copy(configs = remaining, activeId = newActive))
+ }
+
+ // --- Custom menu-slot labels (JSON) ----------------------------------------------------------------
+
+ /** True if custom menu labels (`.labels.json`) exist for [key]. */
+ fun hasLabels(context: Context, key: String): Boolean = labelsFile(context, key).isFile
+
+ /** Load custom menu labels for [key], or null if absent/unparseable. */
+ fun loadLabels(context: Context, key: String): ScMenuLabels? {
+ val f = labelsFile(context, key).takeIf { it.isFile } ?: return null
+ return runCatching { json.decodeFromString(ScMenuLabels.serializer(), f.readText()) }
+ .onFailure { Log.w(TAG, "loadLabels($key) failed: ${it.message}") }
+ .getOrNull()
+ }
+
+ /** Persist custom menu [labels] for [key]; deletes the file when there are no overrides. Returns success. */
+ fun saveLabels(context: Context, key: String, labels: ScMenuLabels): Boolean =
+ runCatching {
+ if (labels.overrides.isEmpty()) labelsFile(context, key).let { if (it.isFile) it.delete() }
+ else labelsFile(context, key).writeText(json.encodeToString(ScMenuLabels.serializer(), labels))
+ true
+ }.onFailure { Log.w(TAG, "saveLabels($key) failed: ${it.message}") }.getOrDefault(false)
+
+ /** Delete custom menu labels for [key]. Returns true if a file was removed. */
+ fun removeLabels(context: Context, key: String): Boolean =
+ labelsFile(context, key).let { if (it.isFile) it.delete() else false }
+
+ // --- Resolution ------------------------------------------------------------------------------------
+
+ /**
+ * Validate raw `.vdf` text without persisting: returns the parsed [ScConfig] (non-empty) or null. Lets the
+ * import UI reject a bad file before saving it.
+ */
+ fun validate(vdfText: String): ScConfig? =
+ runCatching { SteamControllerProfileImporter.importConfig(vdfText).takeIf { it.sets.isNotEmpty() } }
+ .onFailure { Log.w(TAG, "validate failed: ${it.message}") }
+ .getOrNull()
+
+ /**
+ * Resolve the live config for [key]: the [key]'s active config, else the shared [DEFAULT_KEY]'s active config,
+ * with this game's custom menu-slot labels layered on top. Null when nothing applies, so the caller cleanly
+ * falls back to [ScProfile.default].
+ */
+ fun forKey(context: Context, key: String): ScConfig? {
+ val cfg = resolveConfig(context, key) ?: return null
+ val labels = loadLabels(context, key)
+ return if (labels != null) ScMenuLabelTool.apply(cfg, labels) else cfg
+ }
+
+ /** The resolved config WITHOUT custom labels applied — for the label editor to show binding-derived defaults. */
+ fun rawConfig(context: Context, key: String): ScConfig? = resolveConfig(context, key)
+
+ private fun resolveConfig(context: Context, key: String): ScConfig? {
+ resolveActive(context, key)?.let { return it }
+ if (key == DEFAULT_KEY) return null
+ return resolveActive(context, DEFAULT_KEY)
+ }
+
+ /** Load + parse the active config for [key] into a runtime [ScConfig], or null if none/empty/unparseable. */
+ private fun resolveActive(context: Context, key: String): ScConfig? {
+ val entry = registry(context, key).active() ?: return null
+ return when (entry.kind) {
+ ScConfigKind.VDF -> {
+ val base = vdfPayload(context, key, entry.id).takeIf { it.isFile }?.let { parseVdf(it) }
+ // Apply the editable overlay (lossless edits) over the parsed vdf base, if one exists.
+ base?.let { b ->
+ overlayPayload(context, key, entry.id).takeIf { it.isFile }?.let { f ->
+ runCatching { json.decodeFromString(ScEditableConfig.serializer(), f.readText()) }
+ .onFailure { Log.w(TAG, "resolve overlay ${entry.id} failed: ${it.message}") }
+ .getOrNull()?.let { ov -> applyOverlay(b, ov) }
+ } ?: b
+ }
+ }
+ ScConfigKind.AUTHORED -> setsPayload(context, key, entry.id).takeIf { it.isFile }?.let { f ->
+ runCatching { json.decodeFromString(ScEditableConfig.serializer(), f.readText()).toScConfig() }
+ .onFailure { Log.w(TAG, "resolve authored ${entry.id} failed: ${it.message}") }
+ .getOrNull()
+ }
+ }?.also { Log.i(TAG, "resolved $key -> '${entry.name}' (${entry.kind}, sets=${it.sets.keys} default=${it.defaultSetId})") }
+ }
+
+ /**
+ * Apply an editable [overlay] over a parsed-vdf [base] (the lossless-edit path). For each set the base defines,
+ * an overlay set with the same id resolves **against that base set's profile** — so the overlay overrides only
+ * the sources it changed (representable buttons / analog modes / triggers / gyro / haptics) and inherits the
+ * rest (including menus/radials and any [OutputKind.INHERIT] buttons). The base's [ScConfig.setSources] and
+ * [ScConfig.shiftOverlays] (action layers + mode-shift) are preserved untouched, so editing a vdf can't destroy
+ * them. Overlay sets the base lacks (new authored sets) resolve against [ScProfile.default].
+ */
+ private fun applyOverlay(base: ScConfig, overlay: ScEditableConfig): ScConfig {
+ val merged = LinkedHashMap()
+ for ((setId, baseProfile) in base.sets) {
+ val o = overlay.sets.firstOrNull { it.id == setId }
+ merged[setId] = if (o == null) baseProfile else o.profile.toScProfile(baseProfile)
+ }
+ // Sets the overlay ADDS that the base lacks (e.g. a new authored action layer) resolve against default, and
+ // an added layer contributes its derived source list so it merges correctly ([mergeProfiles]).
+ val sources = HashMap(base.setSources)
+ for (o in overlay.sets) if (!base.sets.containsKey(o.id)) {
+ merged[o.id] = o.profile.toScProfile()
+ if (o.isLayer) sources[o.id] = o.profile.definedSources()
+ }
+ val def = if (merged.containsKey(overlay.defaultSetId)) overlay.defaultSetId else base.defaultSetId
+ return ScConfig(sets = merged, defaultSetId = def, setSources = sources, shiftOverlays = base.shiftOverlays)
+ }
+
+ private fun parseVdf(f: File): ScConfig? = runCatching {
+ val cfg = SteamControllerProfileImporter.importConfig(f.readText())
+ if (cfg.sets.isEmpty()) {
+ Log.w(TAG, "${f.name} parsed to 0 sets — ignoring")
+ null
+ } else {
+ cfg
+ }
+ }.onFailure { Log.w(TAG, "parse ${f.name} failed: ${it.message}") }.getOrNull()
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/ScKeyboard.kt b/app/src/main/java/app/gamenative/steamcontroller/ScKeyboard.kt
new file mode 100644
index 0000000000..6590e2dfc6
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/ScKeyboard.kt
@@ -0,0 +1,275 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.xserver.XKeycode
+
+/**
+ * Steam-Controller-style split on-screen keyboard (see docs/SC-KEYBOARD.md). The keyboard is two grids — the
+ * **left trackpad** drives a cursor over the left half, the **right trackpad** over the right half — and a key is
+ * typed by **clicking that pad or pulling that side's trigger** while the cursor is over it. Output goes straight
+ * to the game via [ScOutputSink.key] (`XServer.injectKeyPress`), so no Android IME is involved.
+ *
+ * Pure logic (unit-testable); rendering is [ScKeyboardOverlayView] via the [ScKeyboardOverlay] seam. v1 commits on
+ * click/trigger (not thumb-lift) and supports a sticky Shift; a symbol layer / release-to-type are future work.
+ */
+
+/** One key on the on-screen keyboard. */
+sealed class KbKey(val label: String) {
+ /** A character key that injects [code]. Shift is held when the keyboard's sticky shift is on, OR when
+ * [forceShift] is set (used by symbol-page keys that are always the shifted glyph, e.g. `!` = Shift+1). */
+ class Chr(val code: XKeycode, label: String, val forceShift: Boolean = false) : KbKey(label)
+ object Shift : KbKey("⇧ Shift") // sticky shift (capital for the next letter)
+ object Backspace : KbKey("⌫") // ⌫
+ object Space : KbKey("space")
+ object Enter : KbKey("⏎ Enter")
+ object Close : KbKey("✕") // ✕ close the keyboard
+ object Sym : KbKey("?123") // switch to the symbol page
+ object Abc : KbKey("ABC") // switch back to the letter page
+ object Empty : KbKey("") // unused cell
+}
+
+/** The fixed split-QWERTY layout: two [COLS]×[ROWS] grids, row 0 = TOP, row-major. */
+object ScKeyboardLayout {
+ const val COLS = 5
+ const val ROWS = 5
+
+ private fun k(ch: Char): KbKey = KbKey.Chr(XKeycode.valueOf("KEY_" + ch.uppercaseChar()), ch.toString())
+ /** A symbol key: injects [code], holding Shift for the shifted glyph (e.g. `!` = Shift+KEY_1). */
+ private fun s(code: XKeycode, label: String, shift: Boolean = false) = KbKey.Chr(code, label, forceShift = shift)
+
+ val LEFT: List = listOf(
+ k('1'), k('2'), k('3'), k('4'), k('5'),
+ k('q'), k('w'), k('e'), k('r'), k('t'),
+ k('a'), k('s'), k('d'), k('f'), k('g'),
+ k('z'), k('x'), k('c'), k('v'), k('b'),
+ KbKey.Shift, KbKey.Chr(XKeycode.KEY_COMMA, ","), KbKey.Chr(XKeycode.KEY_PERIOD, "."), KbKey.Chr(XKeycode.KEY_MINUS, "-"), KbKey.Space,
+ )
+
+ val RIGHT: List = listOf(
+ k('6'), k('7'), k('8'), k('9'), k('0'),
+ k('y'), k('u'), k('i'), k('o'), k('p'),
+ k('h'), k('j'), k('k'), k('l'), KbKey.Enter,
+ k('n'), k('m'), KbKey.Backspace, KbKey.Close, KbKey.Sym,
+ KbKey.Space, KbKey.Empty, KbKey.Empty, KbKey.Empty, KbKey.Empty,
+ )
+
+ // Symbol page — same 5×5 split; functional keys (Shift/Space/Enter/Backspace/Close/toggle) stay in place, the
+ // character cells become symbols. Shifted glyphs (! @ # …) hold Shift over the base keycode.
+ val LEFT_SYM: List = listOf(
+ s(XKeycode.KEY_1, "!", true), s(XKeycode.KEY_2, "@", true), s(XKeycode.KEY_3, "#", true), s(XKeycode.KEY_4, "$", true), s(XKeycode.KEY_5, "%", true),
+ s(XKeycode.KEY_6, "^", true), s(XKeycode.KEY_7, "&", true), s(XKeycode.KEY_8, "*", true), s(XKeycode.KEY_9, "(", true), s(XKeycode.KEY_0, ")", true),
+ s(XKeycode.KEY_MINUS, "-"), s(XKeycode.KEY_MINUS, "_", true), s(XKeycode.KEY_EQUAL, "="), s(XKeycode.KEY_EQUAL, "+", true), s(XKeycode.KEY_BACKSLASH, "\\"),
+ s(XKeycode.KEY_BACKSLASH, "|", true), s(XKeycode.KEY_SEMICOLON, ";"), s(XKeycode.KEY_SEMICOLON, ":", true), s(XKeycode.KEY_APOSTROPHE, "'"), s(XKeycode.KEY_APOSTROPHE, "\"", true),
+ KbKey.Shift, s(XKeycode.KEY_GRAVE, "`"), s(XKeycode.KEY_GRAVE, "~", true), s(XKeycode.KEY_SLASH, "/"), KbKey.Space,
+ )
+
+ val RIGHT_SYM: List = listOf(
+ s(XKeycode.KEY_BRACKET_LEFT, "["), s(XKeycode.KEY_BRACKET_RIGHT, "]"), s(XKeycode.KEY_BRACKET_LEFT, "{", true), s(XKeycode.KEY_BRACKET_RIGHT, "}", true), s(XKeycode.KEY_SLASH, "?", true),
+ s(XKeycode.KEY_COMMA, "<", true), s(XKeycode.KEY_PERIOD, ">", true), s(XKeycode.KEY_COMMA, ","), s(XKeycode.KEY_PERIOD, "."), KbKey.Empty,
+ KbKey.Empty, KbKey.Empty, KbKey.Empty, KbKey.Empty, KbKey.Enter,
+ KbKey.Empty, KbKey.Empty, KbKey.Backspace, KbKey.Close, KbKey.Abc,
+ KbKey.Space, KbKey.Empty, KbKey.Empty, KbKey.Empty, KbKey.Empty,
+ )
+
+ fun leftFor(symbols: Boolean): List = if (symbols) LEFT_SYM else LEFT
+ fun rightFor(symbols: Boolean): List = if (symbols) RIGHT_SYM else RIGHT
+
+ /** Pad position (raw ±32768, +Y up) → cell index in a [COLS]×[ROWS] grid (row 0 = top), or -1 if out of range. */
+ fun cellAt(x: Int, y: Int): Int {
+ val nx = (x.toFloat() / 65536f + 0.5f).coerceIn(0f, 0.999f)
+ val ny = (y.toFloat() / 65536f + 0.5f).coerceIn(0f, 0.999f)
+ val col = (nx * COLS).toInt().coerceIn(0, COLS - 1)
+ val rowTop = (ROWS - 1) - (ny * ROWS).toInt().coerceIn(0, ROWS - 1)
+ return rowTop * COLS + col
+ }
+}
+
+/** Render seam for the keyboard HUD; the layout is static ([ScKeyboardLayout]), the spec carries live cursor/shift. */
+interface ScKeyboardOverlay {
+ fun show(spec: ScKeyboardSpec)
+ fun hide()
+}
+
+/**
+ * Live keyboard state for the overlay: highlighted cell on each half (-1 = none) + sticky-shift state, plus the
+ * continuous finger position within each half ([leftX]/[leftY], [rightX]/[rightY]; 0..1, x left→right, y top→
+ * bottom; -1 = that pad isn't touched) so the HUD can draw a cursor dot, not just a cell highlight.
+ */
+data class ScKeyboardSpec(
+ val leftCursor: Int, val rightCursor: Int, val shift: Boolean,
+ val leftX: Float = -1f, val leftY: Float = -1f,
+ val rightX: Float = -1f, val rightY: Float = -1f,
+ val symbols: Boolean = false,
+)
+
+object NoOpScKeyboardOverlay : ScKeyboardOverlay {
+ override fun show(spec: ScKeyboardSpec) {}
+ override fun hide() {}
+}
+
+/**
+ * Drives the split keyboard from [TritonState]s while [active]. Typing fires keys through [sink]; the visual is
+ * pushed to [overlay]. Toggled by [ProfileInterpreter] on a [ScOutput.ShowKeyboard] binding.
+ */
+class ScKeyboard(
+ private val sink: ScOutputSink,
+ private val overlay: ScKeyboardOverlay = NoOpScKeyboardOverlay,
+ /** Trackpad haptics: detent ticks as the cursor crosses cells + a click on type. Keyboard mode suppresses
+ * the interpreter's normal pad-feel path, so the keyboard fires its own. Null = no haptics. */
+ private val haptics: TritonHaptics? = null,
+ /** Time source (ms) for key-repeat timing; override in tests with a virtual clock. */
+ private val clock: () -> Long = { System.currentTimeMillis() },
+ /** Touchpad smoothing 0–100 (shared knob); low-passes the cursor position so the dot/cell don't jitter. */
+ smoothing: Int = 0,
+) {
+ // @Volatile so live dial-in (ProfileInterpreter.setPadTuning) can retune the cursor low-pass mid-game.
+ @Volatile private var smoothing: Int = smoothing
+ fun setSmoothing(value: Int) { smoothing = value }
+
+ var active = false
+ private set
+ private var shift = false
+ private var symbols = false // symbol page vs the letter page (toggled by Sym/Abc)
+ private var leftCursor = -1
+ private var rightCursor = -1
+ private var leftX = -1f
+ private var leftY = -1f
+ private var rightX = -1f
+ private var rightY = -1f
+ private var prevButtons = 0
+ // EMA-smoothed raw pad position per half (NaN = not currently touched / unseeded).
+ private var smLeftX = Float.NaN
+ private var smLeftY = Float.NaN
+ private var smRightX = Float.NaN
+ private var smRightY = Float.NaN
+ private val clickGain = HapticSettings().clickGain
+ private val tickGain = HapticSettings().tickGain
+
+ // Hold-to-repeat: holding a pad-click/trigger over a key types it once, then (after [REPEAT_DELAY_MS]) repeats
+ // every [REPEAT_INTERVAL_MS] while held on the same cell. Per-side state so each thumb repeats independently.
+ private val lRepeat = SideRepeat()
+ private val rRepeat = SideRepeat()
+
+ fun activate() {
+ active = true; shift = false; symbols = false; leftCursor = -1; rightCursor = -1
+ leftX = -1f; leftY = -1f; rightX = -1f; rightY = -1f; prevButtons = 0
+ smLeftX = Float.NaN; smLeftY = Float.NaN; smRightX = Float.NaN; smRightY = Float.NaN
+ lRepeat.reset(); rRepeat.reset()
+ push()
+ }
+
+ fun deactivate() {
+ active = false
+ runCatching { overlay.hide() }
+ }
+
+ /** Process one report while the keyboard is up. */
+ fun update(s: TritonState) {
+ if (!active) return
+ val now = clock()
+ val a = ScTuningStore.emaAlpha(smoothing)
+ // LEFT half: smooth the raw pad position (Touchpad smoothing), then track cursor (+detent tick on cell
+ // change) and type on pad-click/trigger (hold to repeat).
+ val lTouch = s.has(TritonProtocol.BTN_LPAD_TOUCH)
+ if (lTouch) {
+ smLeftX = if (smLeftX.isNaN()) s.leftPadX.toFloat() else smLeftX * (1f - a) + s.leftPadX * a
+ smLeftY = if (smLeftY.isNaN()) s.leftPadY.toFloat() else smLeftY * (1f - a) + s.leftPadY * a
+ } else { smLeftX = Float.NaN; smLeftY = Float.NaN }
+ val lx = smLeftX.toInt(); val ly = smLeftY.toInt()
+ val newLeft = if (lTouch) ScKeyboardLayout.cellAt(lx, ly) else -1
+ if (lTouch && newLeft >= 0 && newLeft != leftCursor) haptics?.tick(TritonHaptics.SIDE_LEFT_PAD, tickGain)
+ leftCursor = newLeft
+ leftX = if (lTouch) normX(lx) else -1f
+ leftY = if (lTouch) normY(ly) else -1f
+ fireSide(s, leftCursor, ScKeyboardLayout.leftFor(symbols), TritonHaptics.SIDE_LEFT_PAD, now, lRepeat,
+ TritonProtocol.BTN_LPAD_CLICK, TritonProtocol.BTN_LTRIG_CLICK)
+ // RIGHT half
+ val rTouch = s.has(TritonProtocol.BTN_RPAD_TOUCH)
+ if (rTouch) {
+ smRightX = if (smRightX.isNaN()) s.rightPadX.toFloat() else smRightX * (1f - a) + s.rightPadX * a
+ smRightY = if (smRightY.isNaN()) s.rightPadY.toFloat() else smRightY * (1f - a) + s.rightPadY * a
+ } else { smRightX = Float.NaN; smRightY = Float.NaN }
+ val rx = smRightX.toInt(); val ry = smRightY.toInt()
+ val newRight = if (rTouch) ScKeyboardLayout.cellAt(rx, ry) else -1
+ if (rTouch && newRight >= 0 && newRight != rightCursor) haptics?.tick(TritonHaptics.SIDE_RIGHT_PAD, tickGain)
+ rightCursor = newRight
+ rightX = if (rTouch) normX(rx) else -1f
+ rightY = if (rTouch) normY(ry) else -1f
+ if (active) fireSide(s, rightCursor, ScKeyboardLayout.rightFor(symbols), TritonHaptics.SIDE_RIGHT_PAD, now, rRepeat,
+ TritonProtocol.BTN_RPAD_CLICK, TritonProtocol.BTN_RTRIG_CLICK)
+ prevButtons = s.buttons
+ if (active) push() else runCatching { overlay.hide() }
+ }
+
+ /**
+ * Type from one half: fire on the rising edge of any [commitBits] (pad-click / trigger) over [cursor], then
+ * auto-repeat while the button stays held on the same cell (initial [REPEAT_DELAY_MS], then [REPEAT_INTERVAL_MS]).
+ * Sliding to a different cell while held suppresses repeat until release, so dragging doesn't machine-gun keys.
+ */
+ private fun fireSide(s: TritonState, cursor: Int, keys: List, side: Int, now: Long, rep: SideRepeat, vararg commitBits: Int) {
+ val down = commitBits.any { (s.buttons and it) != 0 }
+ val rising = commitBits.any { (s.buttons and it) != 0 && (prevButtons and it) == 0 }
+ val key = keys.getOrNull(cursor)
+ when {
+ rising && cursor >= 0 -> {
+ haptics?.click(side, clickGain); fire(key)
+ rep.cell = cursor; rep.nextFire = now + REPEAT_DELAY_MS
+ }
+ down && cursor >= 0 && cursor == rep.cell && repeatable(key) && now >= rep.nextFire -> {
+ haptics?.tick(side, tickGain); fire(key)
+ rep.nextFire = now + REPEAT_INTERVAL_MS
+ }
+ !down -> rep.reset()
+ cursor != rep.cell -> rep.cell = -2 // moved off the pressed cell while held → no repeat until release
+ }
+ }
+
+ /** Which keys auto-repeat when held: characters + backspace/space/enter; not Shift/Close/Empty (one-shot). */
+ private fun repeatable(key: KbKey?): Boolean = when (key) {
+ is KbKey.Chr, KbKey.Backspace, KbKey.Space, KbKey.Enter -> true
+ else -> false
+ }
+
+ // Pad position (raw ±32768, +Y up) → normalized half coords: x left→right, y top→bottom (screen order).
+ private fun normX(x: Int) = (x.toFloat() / 65536f + 0.5f).coerceIn(0f, 0.999f)
+ private fun normY(y: Int) = (1f - (y.toFloat() / 65536f + 0.5f)).coerceIn(0f, 0.999f)
+
+ private fun fire(key: KbKey?) {
+ when (key) {
+ is KbKey.Chr -> {
+ if (shift || key.forceShift) {
+ sink.key(XKeycode.KEY_SHIFT_L, true)
+ sink.key(key.code, true); sink.key(key.code, false)
+ sink.key(XKeycode.KEY_SHIFT_L, false)
+ if (shift) shift = false // sticky one-shot (forceShift keys don't consume it)
+ } else {
+ sink.key(key.code, true); sink.key(key.code, false)
+ }
+ }
+ KbKey.Shift -> shift = !shift
+ KbKey.Sym, KbKey.Abc -> symbols = !symbols
+ KbKey.Space -> pulse(XKeycode.KEY_SPACE)
+ KbKey.Backspace -> pulse(XKeycode.KEY_BKSP)
+ KbKey.Enter -> pulse(XKeycode.KEY_ENTER)
+ KbKey.Close -> deactivate()
+ KbKey.Empty, null -> {}
+ }
+ }
+
+ private fun pulse(code: XKeycode) { sink.key(code, true); sink.key(code, false) }
+
+ private fun push() {
+ runCatching { overlay.show(ScKeyboardSpec(leftCursor, rightCursor, shift, leftX, leftY, rightX, rightY, symbols)) }
+ }
+
+ /** Per-side key-repeat state: [cell] = the cell the held button pressed (-1 idle, -2 = slid off, suppressed). */
+ private class SideRepeat {
+ var cell = -1
+ var nextFire = 0L
+ fun reset() { cell = -1; nextFire = 0L }
+ }
+
+ private companion object {
+ const val REPEAT_DELAY_MS = 350L // hold this long before the first repeat
+ const val REPEAT_INTERVAL_MS = 90L // then repeat at this cadence
+ }
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/ScKeyboardOverlayView.kt b/app/src/main/java/app/gamenative/steamcontroller/ScKeyboardOverlayView.kt
new file mode 100644
index 0000000000..caefa96f77
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/ScKeyboardOverlayView.kt
@@ -0,0 +1,96 @@
+package app.gamenative.steamcontroller
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.view.MotionEvent
+import android.view.View
+
+/**
+ * Renders the split-trackpad keyboard ([ScKeyboard]) — left grid + right grid with both cursors + shift state.
+ * Transparent, ignores touch (input passes to the game), thread-safe via a `@Volatile` spec + [postInvalidate].
+ * The layout is static ([ScKeyboardLayout]); the spec carries the live cursors/shift.
+ *
+ * UNTESTED ON DEVICE (built 2026-06-20) — see docs/RISKS.md §A (overlay z-order) + docs/SC-KEYBOARD.md.
+ */
+class ScKeyboardOverlayView(context: Context) : View(context), ScKeyboardOverlay {
+
+ @Volatile private var spec: ScKeyboardSpec? = null
+ // Placement + size (from ScOverlayStore keyboard namespace); default = full-size, centered low.
+ @Volatile private var layout = ScOverlayStore.KEYBOARD_DEFAULT
+
+ private val backdrop = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(150, 0, 0, 0) }
+ private val cell = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(210, 40, 44, 52); style = Paint.Style.FILL }
+ private val cellHi = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(235, 33, 150, 243); style = Paint.Style.FILL }
+ private val cellShiftOn = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(235, 76, 175, 80); style = Paint.Style.FILL }
+ private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(200, 255, 255, 255); style = Paint.Style.STROKE; strokeWidth = 2f }
+ private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE; textAlign = Paint.Align.CENTER; textSize = 30f }
+ // Finger-position cursor (a bright dot per half) so you can see exactly where your thumb points, not just
+ // which cell is highlighted.
+ private val cursorFill = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(255, 255, 193, 7); style = Paint.Style.FILL }
+ private val cursorStroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(230, 0, 0, 0); style = Paint.Style.STROKE; strokeWidth = 2f }
+
+ init { isClickable = false; isFocusable = false }
+ override fun onTouchEvent(event: MotionEvent?): Boolean = false
+
+ /** Apply a placement/size layout (from [ScOverlayStore.forKeyboard]); takes effect on the next draw. */
+ fun setLayout(l: ScOverlayLayout) { layout = l.clamped(); postInvalidate() }
+
+ override fun show(spec: ScKeyboardSpec) { this.spec = spec; postInvalidate() }
+ override fun hide() { this.spec = null; postInvalidate() }
+
+ override fun onDraw(canvas: Canvas) {
+ val s = spec ?: return
+ val l = layout
+ // Scaled, repositionable keyboard bounds (base = 92% width × 41% height, original split + gap preserved).
+ val kbW = width * 0.92f * l.scale
+ val kbH = height * 0.41f * l.scale
+ val leftEdge = width * l.cx - kbW / 2f
+ val rightEdge = width * l.cx + kbW / 2f
+ val top = height * l.cy - kbH / 2f
+ val bottom = height * l.cy + kbH / 2f
+ val halfW = kbW * 0.4783f // each half is 0.44/0.92 of the total; leaves the centered gap
+ val pad = 10f
+ // Local backdrop behind the keyboard (no full-screen dim).
+ canvas.drawRoundRect(RectF(leftEdge - pad, top - pad, rightEdge + pad, bottom + pad), 18f, 18f, backdrop)
+ text.textSize = 30f * l.scale
+ drawHalf(canvas, ScKeyboardLayout.leftFor(s.symbols), leftEdge, leftEdge + halfW, top, bottom, s.leftCursor, s.shift, s.leftX, s.leftY)
+ drawHalf(canvas, ScKeyboardLayout.rightFor(s.symbols), rightEdge - halfW, rightEdge, top, bottom, s.rightCursor, s.shift, s.rightX, s.rightY)
+ }
+
+ private fun drawHalf(
+ canvas: Canvas, grid: List, left: Float, right: Float, top: Float, bottom: Float,
+ cursor: Int, shift: Boolean, posX: Float, posY: Float,
+ ) {
+ val cols = ScKeyboardLayout.COLS
+ val rows = ScKeyboardLayout.ROWS
+ val cw = (right - left) / cols
+ val ch = (bottom - top) / rows
+ val pad = 4f
+ for (i in grid.indices) {
+ val key = grid[i]
+ if (key is KbKey.Empty) continue
+ val col = i % cols
+ val row = i / cols // row 0 = top
+ val r = RectF(left + col * cw + pad, top + row * ch + pad, left + (col + 1) * cw - pad, top + (row + 1) * ch - pad)
+ val paint = when {
+ i == cursor -> cellHi
+ key is KbKey.Shift && shift -> cellShiftOn
+ else -> cell
+ }
+ canvas.drawRoundRect(r, 8f, 8f, paint)
+ canvas.drawRoundRect(r, 8f, 8f, stroke)
+ val label = if (shift && key is KbKey.Chr && key.label.length == 1) key.label.uppercase() else key.label
+ if (label.isNotBlank()) canvas.drawText(label, r.centerX(), r.centerY() + text.textSize / 3f, text)
+ }
+ // Finger-position cursor dot (only while this pad is touched).
+ if (posX in 0f..1f && posY in 0f..1f) {
+ val px = left + posX * (right - left)
+ val py = top + posY * (bottom - top)
+ canvas.drawCircle(px, py, 11f, cursorFill)
+ canvas.drawCircle(px, py, 11f, cursorStroke)
+ }
+ }
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/ScMenuLabels.kt b/app/src/main/java/app/gamenative/steamcontroller/ScMenuLabels.kt
new file mode 100644
index 0000000000..51fc325ed5
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/ScMenuLabels.kt
@@ -0,0 +1,117 @@
+package app.gamenative.steamcontroller
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Custom labels for radial/touch-menu slots, authored in-app and layered over a resolved [ScConfig].
+ *
+ * Menus (their [MenuSlot.label]s) live inside the *analog* pad/stick modes, which the digital binding editor
+ * ([ScEditableProfile]) doesn't touch — they come from an imported `.vdf` or the built-in default. So custom
+ * labels are stored separately ([ScConfigStore] keys them by config key) and applied in [ScConfigStore.forKey],
+ * so the live driver renders them regardless of where the underlying config came from. The render side already
+ * honors [MenuSlot.label] (overlay shows it over the binding-derived default), so this is purely authoring +
+ * persistence + an apply pass.
+ */
+
+/** Where a radial/touch menu can live on the controller (the four analog sources that can host a menu). */
+enum class ScMenuLocation(val label: String) {
+ LEFT_PAD("Left Pad"), RIGHT_PAD("Right Pad"), LEFT_STICK("Left Stick"), RIGHT_STICK("Right Stick")
+}
+
+/** One label override, keyed by action set + menu location + ring-slot index. */
+@Serializable
+data class MenuLabelOverride(val setId: String, val location: String, val slot: Int, val label: String)
+
+/** Persisted label overrides for one config (a flat list keeps the JSON trivially serializable). */
+@Serializable
+data class ScMenuLabels(val overrides: List = emptyList()) {
+ fun labelFor(setId: String, location: ScMenuLocation, slot: Int): String? =
+ overrides.firstOrNull { it.setId == setId && it.location == location.name && it.slot == slot }
+ ?.label?.takeIf { it.isNotBlank() }
+}
+
+/** A menu found in a config, with each ring slot's default display label — what the editor lists. */
+data class MenuDescriptor(val setId: String, val location: ScMenuLocation, val kind: String, val slotDefaults: List)
+
+/** Enumerate the menus in a config and apply label overrides back onto it. Pure (no Android) → unit-testable. */
+object ScMenuLabelTool {
+
+ private fun padSlots(m: PadMode): List? = when (m) {
+ is PadMode.RadialMenu -> m.slots
+ is PadMode.TouchMenu -> m.slots
+ else -> null
+ }
+
+ private fun stickSlots(m: StickMode): List? = when (m) {
+ is StickMode.RadialMenu -> m.slots
+ is StickMode.TouchMenu -> m.slots
+ else -> null
+ }
+
+ private fun padKind(m: PadMode): String = if (m is PadMode.RadialMenu) "Radial" else "Grid"
+ private fun stickKind(m: StickMode): String = if (m is StickMode.RadialMenu) "Radial" else "Grid"
+
+ /** Default display label for a slot: its existing label, else a binding-derived name, else "Slot N". */
+ fun defaultLabel(slot: MenuSlot, index: Int): String =
+ slot.label.ifBlank { bindingLabel(slot.binding.output) }.ifBlank { "Slot ${index + 1}" }
+
+ private fun bindingLabel(out: ScOutput?): String = when (out) {
+ is ScOutput.Key -> out.keys.joinToString("+") { it.name.removePrefix("KEY_") }
+ is ScOutput.MouseButton -> out.button.name.removePrefix("BUTTON_")
+ is ScOutput.GamepadButton -> "Pad #${out.idx}"
+ else -> ""
+ }
+
+ /** All menus in [cfg], across action sets, in a stable order (set → location), for the editor to list. */
+ fun enumerate(cfg: ScConfig): List {
+ val out = ArrayList()
+ for ((setId, p) in cfg.sets) {
+ fun add(loc: ScMenuLocation, slots: List?, kind: String) {
+ if (slots != null && slots.isNotEmpty()) {
+ out.add(MenuDescriptor(setId, loc, kind, slots.mapIndexed { i, s -> defaultLabel(s, i) }))
+ }
+ }
+ add(ScMenuLocation.LEFT_PAD, padSlots(p.leftPad), padKind(p.leftPad))
+ add(ScMenuLocation.RIGHT_PAD, padSlots(p.rightPad), padKind(p.rightPad))
+ add(ScMenuLocation.LEFT_STICK, stickSlots(p.leftStick), stickKind(p.leftStick))
+ add(ScMenuLocation.RIGHT_STICK, stickSlots(p.rightStick), stickKind(p.rightStick))
+ }
+ return out
+ }
+
+ /** Return a copy of [cfg] with menu-slot labels overridden per [labels]. Untouched if [labels] is empty. */
+ fun apply(cfg: ScConfig, labels: ScMenuLabels): ScConfig {
+ if (labels.overrides.isEmpty()) return cfg
+ val newSets = cfg.sets.mapValues { (setId, p) ->
+ // ScProfile isn't a data class, so rebuild it explicitly, swapping only the four menu-hosting fields.
+ ScProfile(
+ name = p.name,
+ buttons = p.buttons,
+ leftStick = relabelStick(p.leftStick) { labels.labelFor(setId, ScMenuLocation.LEFT_STICK, it) },
+ rightStick = relabelStick(p.rightStick) { labels.labelFor(setId, ScMenuLocation.RIGHT_STICK, it) },
+ leftPad = relabelPad(p.leftPad) { labels.labelFor(setId, ScMenuLocation.LEFT_PAD, it) },
+ rightPad = relabelPad(p.rightPad) { labels.labelFor(setId, ScMenuLocation.RIGHT_PAD, it) },
+ leftTrigger = p.leftTrigger,
+ rightTrigger = p.rightTrigger,
+ gyro = p.gyro,
+ haptics = p.haptics,
+ )
+ }
+ return ScConfig(newSets, cfg.defaultSetId, cfg.setSources, cfg.shiftOverlays)
+ }
+
+ private fun relabel(slots: List, labelOf: (Int) -> String?): List =
+ slots.mapIndexed { i, s -> labelOf(i)?.let { s.copy(label = it) } ?: s }
+
+ private fun relabelPad(m: PadMode, labelOf: (Int) -> String?): PadMode = when (m) {
+ is PadMode.RadialMenu -> m.copy(slots = relabel(m.slots, labelOf))
+ is PadMode.TouchMenu -> m.copy(slots = relabel(m.slots, labelOf))
+ else -> m
+ }
+
+ private fun relabelStick(m: StickMode, labelOf: (Int) -> String?): StickMode = when (m) {
+ is StickMode.RadialMenu -> m.copy(slots = relabel(m.slots, labelOf))
+ is StickMode.TouchMenu -> m.copy(slots = relabel(m.slots, labelOf))
+ else -> m
+ }
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/ScMenuNav.kt b/app/src/main/java/app/gamenative/steamcontroller/ScMenuNav.kt
new file mode 100644
index 0000000000..ea8536719d
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/ScMenuNav.kt
@@ -0,0 +1,37 @@
+package app.gamenative.steamcontroller
+
+/**
+ * The **fixed, user-un-editable** control scheme for navigating GameNative's own menus / SC editors (the QuickMenu
+ * and every in-game Steam-Controller editor). This is the single source of truth: [ProfileInterpreter.handleMenuNav]
+ * drives nav from [controls], and the editor tooltips / help dialog render their labels from the same list — so a
+ * tooltip can never drift from what the button actually does, and every controller navigates our menus identically.
+ *
+ * Deliberately NOT part of the editable [ScProfile] / `.vdf` config: these controls are reserved for menu nav and are
+ * the same regardless of the game's bindings. (Directional focus movement is structural — d-pad OR left stick — so it
+ * isn't a single edge button; it's described by [DIRECTIONS_HINT]/[DIRECTIONS_DESC] rather than a [Control].)
+ */
+object ScMenuNav {
+ /** One fixed edge-triggered menu-nav control: a physical button, the nav intent it fires, and its tooltip text. */
+ data class Control(val key: ScNavKey, val buttonBit: Int, val hint: String, val desc: String)
+
+ const val DIRECTIONS_HINT = "D-pad / Left stick"
+ const val DIRECTIONS_DESC = "Move focus (hold to repeat)"
+
+ /** Edge-triggered controls, in tooltip order. */
+ val controls: List = listOf(
+ Control(ScNavKey.SELECT, TritonProtocol.BTN_A, "A", "Select"),
+ Control(ScNavKey.BACK, TritonProtocol.BTN_B, "B", "Back (one level)"),
+ Control(ScNavKey.BACK, TritonProtocol.BTN_STEAM, "Steam", "Close menu"),
+ Control(ScNavKey.TAB_PREV, TritonProtocol.BTN_LBUMPER, "LB", "Previous tab / action set"),
+ Control(ScNavKey.TAB_NEXT, TritonProtocol.BTN_RBUMPER, "RB", "Next tab / action set"),
+ Control(ScNavKey.HELP, TritonProtocol.BTN_Y, "Y", "Help"),
+ Control(ScNavKey.CLOSE, TritonProtocol.BTN_VIEW, "Start", "Close editor (back to game)"),
+ )
+
+ /** Tooltip label for the control that fires [key] (e.g. HELP -> "Y"), or empty if none. */
+ fun hintFor(key: ScNavKey): String = controls.firstOrNull { it.key == key }?.hint ?: ""
+
+ /** Multi-line "how to navigate" text for the help dialog — derived, so it's always accurate. */
+ fun helpLines(): List =
+ listOf("$DIRECTIONS_HINT — $DIRECTIONS_DESC") + controls.map { "${it.hint} — ${it.desc}" }
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/ScMenuOverlay.kt b/app/src/main/java/app/gamenative/steamcontroller/ScMenuOverlay.kt
new file mode 100644
index 0000000000..1797ca537c
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/ScMenuOverlay.kt
@@ -0,0 +1,45 @@
+package app.gamenative.steamcontroller
+
+/**
+ * Step-6 overlay HUD seam. [ProfileInterpreter] (pure logic, background thread) calls this when a Radial/Touch
+ * menu is active so a UI layer can draw the ring/grid + highlight the selected slot. Kept as a tiny interface
+ * with no Android types so the interpreter stays unit-testable; the real Android renderer is `ScMenuOverlayView`.
+ * Calls are best-effort — the interpreter wraps them so a UI failure can never break input.
+ */
+interface ScMenuOverlay {
+ /** Show/update the menu with the currently highlighted slot (or -1 = none). Called while the pad is touched. */
+ fun showMenu(spec: ScMenuSpec)
+ /** Hide the menu (pad released / committed / mode changed). */
+ fun hideMenu()
+ /** Briefly show a centered status toast (e.g. the new action-set name on a set switch); auto-fades. */
+ fun toast(text: String) {}
+}
+
+/** A snapshot of the active menu for the overlay to draw. */
+data class ScMenuSpec(
+ val kind: Kind,
+ /** Per-slot display labels (may be blank), in slot order. */
+ val labels: List,
+ /** Grid dimensions for [Kind.GRID]; ignored for [Kind.RADIAL]. */
+ val cols: Int = 0,
+ val rows: Int = 0,
+ /** Index of the highlighted slot, or -1 when nothing is selected (finger in the dead-zone). */
+ val highlighted: Int = -1,
+ /** Radial center button label (Steam `touch_menu_button_0`); null = no center. Drawn in the ring's middle. */
+ val centerLabel: String? = null,
+ /** Live source position for a cursor dot (radial only): normalized −1..1, x right+, y up+; NaN = no cursor.
+ * Lets the HUD show exactly where the thumb points (incl. resting over the center "Wait" hub). */
+ val cursorX: Float = Float.NaN,
+ val cursorY: Float = Float.NaN,
+ /** Which surface hosts this menu ("LEFT_PAD"/"RIGHT_PAD"/"LEFT_STICK"/"RIGHT_STICK", = [ScMenuLocation.name]);
+ * the overlay resolves per-menu placement by this id so each menu can sit in its own spot. "" = unscoped. */
+ val menuId: String = "",
+) {
+ enum class Kind { RADIAL, GRID }
+}
+
+/** Default sink that draws nothing (used in tests / when no UI is attached). */
+object NoOpScMenuOverlay : ScMenuOverlay {
+ override fun showMenu(spec: ScMenuSpec) {}
+ override fun hideMenu() {}
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/ScMenuOverlayView.kt b/app/src/main/java/app/gamenative/steamcontroller/ScMenuOverlayView.kt
new file mode 100644
index 0000000000..b6ccad5f07
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/ScMenuOverlayView.kt
@@ -0,0 +1,208 @@
+package app.gamenative.steamcontroller
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.view.View
+import kotlin.math.cos
+import kotlin.math.hypot
+import kotlin.math.min
+import kotlin.math.sin
+
+/**
+ * Step-6 menu HUD renderer: a transparent, non-interactive overlay View that draws a Radial ring or a Touch grid
+ * with per-slot labels and highlights the selected slot. Driven by [ProfileInterpreter] via the [ScMenuOverlay]
+ * interface; updates arrive on the controller thread, so state is held in a `@Volatile` field and the View is
+ * refreshed with [postInvalidate] (thread-safe). The View ignores touches so it never steals game input.
+ *
+ * UNTESTED ON DEVICE (built 2026-06-20) — see docs/RISKS.md §overlay and docs/TESTING-GUIDE.md.
+ */
+class ScMenuOverlayView(context: Context) : View(context), ScMenuOverlay {
+
+ @Volatile private var spec: ScMenuSpec? = null
+ // Placement + size (scale, center fraction); set from ScOverlayStore. Default is intentionally smaller
+ // than 1.0 (user feedback: the HUD was a bit large) and centered. Used directly by the editor preview (which
+ // pushes an explicit layout via setLayout); the live game instead resolves per-menu (see [gameKey]).
+ @Volatile private var layout = ScOverlayLayout()
+ // Live game: resolve each menu's placement per its [ScMenuSpec.menuId] via ScOverlayStore.forMenu, keyed by
+ // this game. Null = editor/preview mode, which uses the pushed [layout] instead. Cache avoids a prefs read
+ // per frame; refreshLayouts() clears it after an in-game overlay edit so the change applies live.
+ @Volatile var gameKey: String? = null
+ private val layoutCache = java.util.concurrent.ConcurrentHashMap()
+ fun refreshLayouts() { layoutCache.clear(); postInvalidate() }
+ // Fade behaviour: the HUD is full-opacity while the control is being actively used (showMenu called each
+ // report), and fades out over FADE_MS once interaction stops (hideMenu) — per the SC overlay UX.
+ @Volatile private var fadingOut = false
+ private var fadeStartMs = 0L
+ private val fadeMs = 180L
+
+ // Local backdrop (a soft halo just behind the ring/grid) — replaces the old full-screen dim so a held
+ // movement radial no longer tints the whole game.
+ private val backdrop = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(110, 0, 0, 0) }
+ private val slotFill = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(200, 40, 44, 52); style = Paint.Style.FILL }
+ private val slotFillHi = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(230, 33, 150, 243); style = Paint.Style.FILL }
+ private val slotStroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(220, 255, 255, 255); style = Paint.Style.STROKE; strokeWidth = 3f }
+ private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE; textAlign = Paint.Align.CENTER; textSize = 44f }
+ // Thumb-position cursor dot (amber, matching the keyboard) so you can see exactly where the stick points.
+ private val cursorFill = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(255, 255, 193, 7); style = Paint.Style.FILL }
+ private val cursorStroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(230, 0, 0, 0); style = Paint.Style.STROKE; strokeWidth = 2f }
+
+ // Transient status toast (e.g. action-set name on a switch); independent of the menu, auto-fades.
+ @Volatile private var toastText: String? = null
+ private var toastStartMs = 0L
+ private val toastMs = 1300L
+ private val toastBg = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.argb(190, 0, 0, 0); style = Paint.Style.FILL }
+ private val toastTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE; textAlign = Paint.Align.CENTER; textSize = 42f }
+
+ init {
+ isClickable = false
+ isFocusable = false
+ }
+
+ // Never consume touch events — input must pass through to the game.
+ override fun onTouchEvent(event: android.view.MotionEvent?): Boolean = false
+
+ /** Apply a placement/size layout (from [ScOverlayStore]); takes effect on the next draw. */
+ fun setLayout(l: ScOverlayLayout) { layout = l.clamped(); postInvalidate() }
+
+ override fun showMenu(spec: ScMenuSpec) {
+ this.spec = spec
+ fadingOut = false // active interaction -> stay at full opacity
+ postInvalidate()
+ }
+
+ override fun hideMenu() {
+ // Begin a fade-out rather than vanishing instantly; keep the spec until the fade completes.
+ if (spec != null && !fadingOut) { fadingOut = true; fadeStartMs = System.currentTimeMillis() }
+ postInvalidate()
+ }
+
+ override fun toast(text: String) {
+ toastText = text
+ toastStartMs = System.currentTimeMillis()
+ postInvalidate()
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ drawToast(canvas) // independent of the menu — may show with nothing else on screen
+ val s = spec ?: return
+ if (s.labels.isEmpty()) return
+ val alpha: Float = if (fadingOut) {
+ val t = (System.currentTimeMillis() - fadeStartMs).toFloat() / fadeMs
+ if (t >= 1f) { spec = null; fadingOut = false; return } // fully faded -> stop drawing
+ (1f - t).coerceIn(0f, 1f)
+ } else 1f
+
+ val layer = canvas.saveLayerAlpha(0f, 0f, width.toFloat(), height.toFloat(), (alpha * 255).toInt())
+ // Live game: per-menu placement (cached); editor/preview: the pushed layout.
+ val l = gameKey?.let { gk -> layoutCache.getOrPut(s.menuId) { ScOverlayStore.forMenu(context, gk, s.menuId) } } ?: layout
+ when (s.kind) {
+ ScMenuSpec.Kind.RADIAL -> drawRadial(canvas, s, l)
+ ScMenuSpec.Kind.GRID -> drawGrid(canvas, s, l)
+ }
+ canvas.restoreToCount(layer)
+ if (fadingOut) postInvalidateOnAnimation() // keep animating the fade
+ }
+
+ private fun drawToast(canvas: Canvas) {
+ val txt = toastText ?: return
+ val t = (System.currentTimeMillis() - toastStartMs).toFloat() / toastMs
+ if (t >= 1f) { toastText = null; return }
+ val alpha = (1f - t).coerceIn(0f, 1f)
+ val layer = canvas.saveLayerAlpha(0f, 0f, width.toFloat(), height.toFloat(), (alpha * 255).toInt())
+ val cx = width / 2f
+ val cy = height * 0.16f
+ val tw = toastTextPaint.measureText(txt)
+ val padX = 28f
+ val r = RectF(cx - tw / 2 - padX, cy - 44f, cx + tw / 2 + padX, cy + 20f)
+ canvas.drawRoundRect(r, 18f, 18f, toastBg)
+ canvas.drawText(txt, cx, cy, toastTextPaint)
+ canvas.restoreToCount(layer)
+ postInvalidateOnAnimation()
+ }
+
+ private fun drawRadial(canvas: Canvas, s: ScMenuSpec, l: ScOverlayLayout) {
+ val base = min(width, height)
+ val cx = width * l.cx
+ val cy = height * l.cy
+ val n = s.labels.size.coerceAtLeast(1)
+ val ringR = base * 0.27f * l.scale
+ // Slot radius: as big as fits without adjacent slots overlapping (tangent spacing = 2·ringR·sin(π/n)),
+ // capped at a comfortable max — so a dense ring shrinks its slots instead of colliding. The tangent limit
+ // only applies with ≥2 slots (sin(π/1)=0 would otherwise collapse a single slot to the floor).
+ val maxSlotR = base * 0.095f * l.scale
+ val tangentLimit = if (n >= 2) (ringR * sin(Math.PI / n) * 0.9).toFloat() else Float.MAX_VALUE
+ val slotR = minOf(maxSlotR, tangentLimit).coerceAtLeast(base * 0.03f * l.scale)
+ text.textSize = (slotR * 0.7f).coerceAtMost(44f * l.scale)
+ // Center hub fills the middle void; the more ring buttons, the bigger the hub (minimize wasted space).
+ // [voidR] is the empty middle radius (to the ring slots' inner edges); never let the hub overlap them.
+ val voidR = ringR - slotR
+ val fillFrac = ((n - 2) / 7f).coerceIn(0f, 1f) * 0.6f + 0.28f // n≤2 → small hub, n≥9 → fills most of the void
+ val centerR = (voidR * fillFrac).coerceIn(slotR * 0.6f, voidR - slotR * 0.3f)
+ // Local halo behind the ring (replaces the old full-screen dim).
+ canvas.drawCircle(cx, cy, ringR + slotR * 1.4f, backdrop)
+ for (i in 0 until n) {
+ // slot 0 at top (12 o'clock), clockwise — matches the interpreter's angle mapping.
+ val ang = Math.toRadians((360.0 / n) * i - 90.0)
+ val x = cx + (ringR * cos(ang)).toFloat()
+ val y = cy + (ringR * sin(ang)).toFloat()
+ val hi = i == s.highlighted
+ canvas.drawCircle(x, y, slotR, if (hi) slotFillHi else slotFill)
+ canvas.drawCircle(x, y, slotR, slotStroke)
+ drawLabel(canvas, s.labels[i], x, y)
+ }
+ // Cursor magnitude (0 at center .. 1 at the rim); used to highlight the center hub when the thumb rests there.
+ val hasCursor = !s.cursorX.isNaN() && !s.cursorY.isNaN()
+ val mag = if (hasCursor) min(1f, hypot(s.cursorX, s.cursorY)) else 1f
+ // Center button (Steam radial_menu button_0, e.g. "Wait") — the neutral hub. Highlighted while the thumb
+ // rests over it (within the center dead-zone), so you can see it's the active selection before clicking.
+ s.centerLabel?.let {
+ val overCenter = hasCursor && mag < 0.30f
+ canvas.drawCircle(cx, cy, centerR, if (overCenter) slotFillHi else slotFill)
+ canvas.drawCircle(cx, cy, centerR, slotStroke)
+ drawLabel(canvas, it, cx, cy)
+ }
+ // Thumb cursor dot: sits at the stick deflection (center when idle, out toward a slot when pushed).
+ if (hasCursor) {
+ val px = cx + s.cursorX * ringR
+ val py = cy - s.cursorY * ringR // screen Y is inverted (cursorY up+)
+ canvas.drawCircle(px, py, slotR * 0.42f, cursorFill)
+ canvas.drawCircle(px, py, slotR * 0.42f, cursorStroke)
+ }
+ }
+
+ private fun drawGrid(canvas: Canvas, s: ScMenuSpec, l: ScOverlayLayout) {
+ val cols = s.cols.coerceAtLeast(1)
+ val rows = s.rows.coerceAtLeast(1)
+ val gridW = min(width, height) * 0.7f * l.scale
+ val gridH = gridW * rows / cols
+ val left = width * l.cx - gridW / 2f
+ val top = height * l.cy - gridH / 2f
+ val cellW = gridW / cols
+ val cellH = gridH / rows
+ val pad = 6f * l.scale
+ // Scale the label to the cell instead of a fixed 44px, so multi-column grids don't collide/overflow the slots.
+ val inner = (min(cellW, cellH) - 2f * pad).coerceAtLeast(1f)
+ text.textSize = (inner * 0.34f).coerceAtMost(44f * l.scale).coerceAtLeast(12f)
+ // Local halo behind the grid.
+ canvas.drawRoundRect(RectF(left - pad, top - pad, left + gridW + pad, top + gridH + pad), 16f, 16f, backdrop)
+ for (i in s.labels.indices) {
+ val col = i % cols
+ val row = i / cols
+ val r = RectF(left + col * cellW + pad, top + row * cellH + pad, left + (col + 1) * cellW - pad, top + (row + 1) * cellH - pad)
+ val hi = i == s.highlighted
+ canvas.drawRoundRect(r, 12f, 12f, if (hi) slotFillHi else slotFill)
+ canvas.drawRoundRect(r, 12f, 12f, slotStroke)
+ drawLabel(canvas, s.labels[i], r.centerX(), r.centerY())
+ }
+ }
+
+ private fun drawLabel(canvas: Canvas, label: String, x: Float, y: Float) {
+ if (label.isBlank()) return
+ // truncate long labels to keep them inside the slot
+ val shown = if (label.length > 10) label.take(9) + "…" else label
+ canvas.drawText(shown, x, y + text.textSize / 3f, text)
+ }
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/ScOutputSink.kt b/app/src/main/java/app/gamenative/steamcontroller/ScOutputSink.kt
new file mode 100644
index 0000000000..15f2bf5ec1
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/ScOutputSink.kt
@@ -0,0 +1,52 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.inputcontrols.GamepadState
+import com.winlator.xserver.Pointer
+import com.winlator.xserver.XKeycode
+import com.winlator.xserver.XServer
+
+/**
+ * The output seam the [ProfileInterpreter] drives. Decoupling the interpreter from [XServer] behind this
+ * interface lets the whole mapping engine be unit-tested on the PC (a recording fake sink) by replaying a
+ * captured input trace — no device, no Winlator runtime. See docs/AUTOMATION-PLAN.md.
+ */
+interface ScOutputSink {
+ /** Push the current virtual XInput pad state. */
+ fun gamepad(state: GamepadState)
+ /** Relative mouse motion. */
+ fun mouseMove(dx: Int, dy: Int)
+ /** Absolute mouse position as a screen fraction (0..1, origin top-left). The sink scales to the X screen. */
+ fun mouseMoveAbs(nx: Float, ny: Float)
+ /** Mouse button down/up. */
+ fun mouseButton(button: Pointer.Button, pressed: Boolean)
+ /** Keyboard key down/up. */
+ fun key(key: XKeycode, pressed: Boolean)
+}
+
+/** Real sink: forwards to GameNative's injection API (virtual pad via WinHandler, mouse/keys via XServer). */
+class XServerOutputSink(private val xServer: XServer) : ScOutputSink {
+ override fun gamepad(state: GamepadState) {
+ val wh = xServer.winHandler
+ wh?.sendVirtualGamepadState(state)
+ wh?.currentController?.state?.copy(state)
+ }
+
+ override fun mouseMove(dx: Int, dy: Int) {
+ xServer.injectPointerMoveDelta(dx, dy)
+ }
+
+ override fun mouseMoveAbs(nx: Float, ny: Float) {
+ val info = xServer.screenInfo ?: return
+ val x = (nx.coerceIn(0f, 1f) * (info.width - 1)).toInt()
+ val y = (ny.coerceIn(0f, 1f) * (info.height - 1)).toInt()
+ xServer.injectPointerMove(x, y)
+ }
+
+ override fun mouseButton(button: Pointer.Button, pressed: Boolean) {
+ if (pressed) xServer.injectPointerButtonPress(button) else xServer.injectPointerButtonRelease(button)
+ }
+
+ override fun key(key: XKeycode, pressed: Boolean) {
+ if (pressed) xServer.injectKeyPress(key) else xServer.injectKeyRelease(key)
+ }
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/ScOverlayStore.kt b/app/src/main/java/app/gamenative/steamcontroller/ScOverlayStore.kt
new file mode 100644
index 0000000000..f597187466
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/ScOverlayStore.kt
@@ -0,0 +1,114 @@
+package app.gamenative.steamcontroller
+
+import android.content.Context
+
+/**
+ * Placement + size of the menu HUD overlay ([ScMenuOverlayView]). [scale] multiplies the built-in ring/grid
+ * size; [cx]/[cy] are the HUD center as a fraction of the screen (0..1, 0.5 = centered). Persisted by
+ * [ScOverlayStore]; tweakable globally and per-game via the drag+pinch editor.
+ */
+data class ScOverlayLayout(
+ val scale: Float = ScOverlayStore.DEFAULT_SCALE,
+ val cx: Float = 0.5f,
+ val cy: Float = 0.5f,
+) {
+ fun clamped() = ScOverlayLayout(
+ scale = scale.coerceIn(ScOverlayStore.MIN_SCALE, ScOverlayStore.MAX_SCALE),
+ cx = cx.coerceIn(0.05f, 0.95f),
+ cy = cy.coerceIn(0.05f, 0.95f),
+ )
+}
+
+/**
+ * Persists [ScOverlayLayout] for the menu HUD. Resolution mirrors [ScConfigStore]: a per-game entry (keyed by
+ * container/appId) wins, else the shared global default ([DEFAULT_KEY]), else the built-in [ScOverlayLayout].
+ * Backed by SharedPreferences (small, no file/JSON parsing).
+ */
+object ScOverlayStore {
+ const val DEFAULT_KEY = "_default"
+ const val DEFAULT_SCALE = 0.7f
+ const val MIN_SCALE = 0.3f
+ const val MAX_SCALE = 2.0f
+ private const val PREFS = "sc_overlay"
+ // The split keyboard is a second overlay with its own placement; namespaced under "kb_" so it doesn't
+ // collide with the menu HUD's entries. Default = full size, centered low (matches the original layout).
+ private const val KB = "kb_"
+ val KEYBOARD_DEFAULT = ScOverlayLayout(scale = 1f, cx = 0.5f, cy = 0.755f)
+
+ private fun prefs(context: Context) =
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+
+ /** Resolved layout for [key]: own entry → global default → built-in. */
+ fun forKey(context: Context, key: String?): ScOverlayLayout {
+ val p = prefs(context)
+ if (key != null && has(p, key)) return read(p, key)
+ if (has(p, DEFAULT_KEY)) return read(p, DEFAULT_KEY)
+ return ScOverlayLayout()
+ }
+
+ /** The stored layout for exactly [key] (no fallback), or null if none — for the editor's initial state. */
+ fun rawFor(context: Context, key: String): ScOverlayLayout? {
+ val p = prefs(context)
+ return if (has(p, key)) read(p, key) else null
+ }
+
+ fun save(context: Context, key: String, layout: ScOverlayLayout) {
+ val l = layout.clamped()
+ prefs(context).edit()
+ .putFloat("$key.scale", l.scale)
+ .putFloat("$key.cx", l.cx)
+ .putFloat("$key.cy", l.cy)
+ .apply()
+ }
+
+ /** Remove [key]'s entry (e.g. a per-game override reverting to the global default). */
+ fun clear(context: Context, key: String) {
+ prefs(context).edit()
+ .remove("$key.scale").remove("$key.cx").remove("$key.cy")
+ .apply()
+ }
+
+ // ---- Per-menu placement ("m__" namespace) ----
+ // Each menu (identified by its host surface, e.g. "LEFT_PAD") can sit in its own spot. Resolution falls back
+ // gracefully so nothing regresses: per-menu-per-game → per-menu-global → the game-wide menu HUD placement
+ // ([forKey], keyed bare) → the global menu default → built-in. So an existing whole-HUD placement still
+ // applies to every menu until a per-menu override refines one.
+ private fun menuKey(menuId: String, key: String) = "m_${menuId}_$key"
+
+ /** Resolved layout for one menu instance on [gameKey]. See the fallback chain above. */
+ fun forMenu(context: Context, gameKey: String?, menuId: String): ScOverlayLayout {
+ if (menuId.isBlank()) return forKey(context, gameKey)
+ val p = prefs(context)
+ if (gameKey != null && has(p, menuKey(menuId, gameKey))) return read(p, menuKey(menuId, gameKey))
+ if (has(p, menuKey(menuId, DEFAULT_KEY))) return read(p, menuKey(menuId, DEFAULT_KEY))
+ return forKey(context, gameKey) // fall back to the whole-HUD placement (per-game → global → built-in)
+ }
+
+ fun saveMenu(context: Context, gameKey: String, menuId: String, layout: ScOverlayLayout) =
+ save(context, menuKey(menuId, gameKey), layout)
+
+ /** Whether a per-menu override exists for exactly this (game, menu) — for the editor's "Use global" state. */
+ fun hasMenu(context: Context, gameKey: String, menuId: String) = has(prefs(context), menuKey(menuId, gameKey))
+
+ fun clearMenu(context: Context, gameKey: String, menuId: String) = clear(context, menuKey(menuId, gameKey))
+
+ // ---- Keyboard overlay (separate placement, "kb_" namespace) ----
+ /** Resolved keyboard layout for [key]: own → global default → built-in [KEYBOARD_DEFAULT]. */
+ fun forKeyboard(context: Context, key: String?): ScOverlayLayout {
+ val p = prefs(context)
+ if (key != null && has(p, KB + key)) return read(p, KB + key)
+ if (has(p, KB + DEFAULT_KEY)) return read(p, KB + DEFAULT_KEY)
+ return KEYBOARD_DEFAULT
+ }
+
+ fun saveKeyboard(context: Context, key: String, layout: ScOverlayLayout) = save(context, KB + key, layout)
+ fun clearKeyboard(context: Context, key: String) = clear(context, KB + key)
+
+ private fun has(p: android.content.SharedPreferences, key: String) = p.contains("$key.scale")
+
+ private fun read(p: android.content.SharedPreferences, key: String) = ScOverlayLayout(
+ scale = p.getFloat("$key.scale", DEFAULT_SCALE),
+ cx = p.getFloat("$key.cx", 0.5f),
+ cy = p.getFloat("$key.cy", 0.5f),
+ ).clamped()
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/ScProfile.kt b/app/src/main/java/app/gamenative/steamcontroller/ScProfile.kt
new file mode 100644
index 0000000000..e4d88489b8
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/ScProfile.kt
@@ -0,0 +1,533 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.inputcontrols.ExternalController
+import com.winlator.xserver.Pointer
+import com.winlator.xserver.XKeycode
+
+/**
+ * The Steam Controller mapping profile model + its interpreter's data types. A [ScProfile] describes how
+ * each physical SC source (buttons, sticks, trackpads, triggers, gyro) maps to GameNative outputs
+ * (virtual XInput pad, mouse, keys). [ProfileInterpreter] consumes a decoded [TritonState] + a profile and
+ * drives GameNative's injection seams.
+ *
+ * This replaces the hardcoded map that lived in TritonMapper. The shipped default ([ScProfile.default]) is
+ * expressed *in this model*, so the interpreter is exercised end-to-end and behaviour is identical to the
+ * old hardcoded path. The sealed mode/output types intentionally carry one case each for now; later build
+ * steps add cases (pad grid, d-pad, scroll, activators, action sets) without changing the interpreter's
+ * shape. See docs/STEAM-INPUT-FEATURES.md (build order) and docs/SC-MAPPING-ENGINE.md.
+ */
+
+/** What a single digital (button-like) SC bit emits. Gamepad outputs are level-based; key/mouse are edge-based. */
+sealed class ScOutput {
+ /** No output (explicitly unbound). */
+ object None : ScOutput()
+ /** A virtual XInput pad button, by ExternalController.IDX_BUTTON_* index. Driven by current pressed state. */
+ data class GamepadButton(val idx: Int) : ScOutput()
+ /** A virtual XInput d-pad direction: 0=up, 1=right, 2=down, 3=left (GamepadState.dpad order). */
+ data class GamepadDpad(val index: Int) : ScOutput()
+ /** A mouse button, pressed/released on the bit's edges. */
+ data class MouseButton(val button: Pointer.Button) : ScOutput()
+ /**
+ * A keyboard output: one key, or a combo (modifiers + key) held together. On press all keys go down in
+ * order; on release they come up in reverse order. A single key is just a one-element list.
+ */
+ data class Key(val keys: List) : ScOutput() {
+ constructor(key: XKeycode) : this(listOf(key))
+ }
+ /**
+ * Switch the active **action set** (Steam `CHANGE_PRESET`). Fired on the binding's edge: [onRelease] = false
+ * presses-edge (e.g. `Start_Press` → enter a menu set), true = release-edge (e.g. the menu set's same button
+ * → return). The "hold for menus" feel comes from each set re-binding the button (enter on press in set A,
+ * leave on release in set B), so no momentary state is needed. Handled by [ProfileInterpreter] only when an
+ * [ScConfig] is installed; a no-op otherwise. [targetSetId] is the Steam preset id.
+ */
+ data class SwitchActionSet(val targetSetId: String, val onRelease: Boolean = false) : ScOutput()
+ /**
+ * Push/pop an action **layer** (Steam `add_layer`/`hold_layer`/`remove_layer`). A layer is a partial overlay
+ * (another preset) merged over the active set — see [mergeProfiles]. [op] picks the behaviour: ADD = push
+ * until removed, REMOVE = pop, HOLD = push while the button is held (popped on release). [layerId] = Steam
+ * preset id. Handled by [ProfileInterpreter] only when an [ScConfig] is installed.
+ */
+ data class LayerOp(val layerId: String, val op: LayerOpType) : ScOutput()
+ /**
+ * Mode-shift (Steam `mode_shift `): while this button is held, a single [source] (e.g.
+ * "left_trackpad") momentarily uses an alternate group's mode/bindings. [groupId] is the raw group id; its
+ * decoded single-source overlay lives in [ScConfig.shiftOverlays] and is merged over the active profile only
+ * while held ([mergeProfiles] with `layerSources={source}`). Handled by [ProfileInterpreter] with a config.
+ */
+ data class ModeShift(val source: String, val groupId: String) : ScOutput()
+ /** Toggle the on-screen split-trackpad keyboard (Steam `controller_action SHOW_KEYBOARD`). Handled by
+ * [ProfileInterpreter]: on the press edge it flips keyboard mode (pads drive the keyboard, see [ScKeyboard]). */
+ object ShowKeyboard : ScOutput()
+ /** Open GameNative's in-game QuickMenu (and let the controller navigate it). On the press edge the
+ * [ProfileInterpreter] calls [ScUiBridge.openQuickMenu]; while any menu/editor is up the interpreter routes
+ * controller input to Android focus-nav keys instead of the game. Default-bound to the Steam button. */
+ object OpenQuickMenu : ScOutput()
+ /** A one-shot relative mouse nudge (Steam `controller_action mouse_delta dx dy`): emits a single
+ * [ScOutputSink.mouseMove] on the press edge. */
+ data class MouseNudge(val dx: Int, val dy: Int) : ScOutput()
+ /** Warp the cursor to an absolute screen position (Steam `controller_action MOUSE_POSITION x y return`):
+ * on the press edge emits [ScOutputSink.mouseMoveAbs] to ([nx],[ny]) (screen fraction 0..1). ([returnAfter]
+ * auto-return-after is not yet honored — needs the sink to read the current position first.) */
+ data class MousePosition(val nx: Float, val ny: Float, val returnAfter: Boolean = false) : ScOutput()
+ /**
+ * A macro (Steam's repeated same-type activators): a sequence of [commands] played **once on press**
+ * (one-shot; holding doesn't repeat, releasing doesn't interrupt). Commands run in order, each framed by its
+ * own `delay_start`/`delay_end`; a command's [MacroCommand.outputs] are pressed **together** (a chord) for the
+ * step. Sub-outputs may be keys/mouse/gamepad. Played by [ProfileInterpreter] via its timed scheduler.
+ */
+ data class Macro(val commands: List) : ScOutput()
+}
+
+/** One step of an [ScOutput.Macro]: [outputs] are held together for the step, framed by the per-command delays (ms). */
+data class MacroCommand(val outputs: List, val delayStartMs: Long = 0, val delayEndMs: Long = 0)
+
+enum class LayerOpType { ADD, HOLD, REMOVE }
+
+/**
+ * Merge a [layer] (partial overlay) over a [base] profile. The layer overrides exactly the sources it binds —
+ * [layerSources] is the set of `group_source_bindings` sources the layer defines (e.g. "right_trackpad",
+ * "joystick"); every other source falls through to [base]. Button bits the layer binds win per-bit (covers the
+ * digital sources button_diamond/switch/dpad + clicks). This is Steam's action-layer semantics: a layer changes
+ * only what it touches and leaves the rest of the active set intact.
+ */
+fun mergeProfiles(base: ScProfile, layer: ScProfile, layerSources: Set): ScProfile {
+ fun has(src: String) = src in layerSources
+ return ScProfile(
+ name = base.name,
+ buttons = base.buttons + layer.buttons,
+ leftStick = if (has("joystick")) layer.leftStick else base.leftStick,
+ rightStick = if (has("right_joystick")) layer.rightStick else base.rightStick,
+ leftPad = if (has("left_trackpad")) layer.leftPad else base.leftPad,
+ rightPad = if (has("right_trackpad")) layer.rightPad else base.rightPad,
+ leftTrigger = if (has("left_trigger")) layer.leftTrigger else base.leftTrigger,
+ rightTrigger = if (has("right_trigger")) layer.rightTrigger else base.rightTrigger,
+ gyro = if (has("gyro")) layer.gyro else base.gyro,
+ haptics = base.haptics,
+ )
+}
+
+/**
+ * A whole controller config = several named action sets ([sets], keyed by Steam **preset id** since that's what
+ * `CHANGE_PRESET`/[ScOutput.SwitchActionSet] targets) plus which one is active at launch ([defaultSetId]).
+ * Produced by `SteamControllerProfileImporter.importConfig`; consumed by [ProfileInterpreter] to drive
+ * config-defined action-set switching. (Action *layers* / mode-shift / chord are later build-step-3 additions.)
+ */
+class ScConfig(
+ val sets: Map,
+ val defaultSetId: String,
+ /** Per-preset-id set of `group_source_bindings` sources it defines — drives action-layer merging ([mergeProfiles]). */
+ val setSources: Map> = emptyMap(),
+ /** Decoded single-source overlays for [ScOutput.ModeShift], keyed by the target group id. */
+ val shiftOverlays: Map = emptyMap(),
+) {
+ /** The set to start in (falls back to any set, then an empty profile). */
+ fun defaultProfile(): ScProfile = sets[defaultSetId] ?: sets.values.firstOrNull() ?: ScProfile()
+}
+
+/**
+ * Per-binding press logic (Steam's "activators"). Only affects edge outputs (Key / MouseButton); gamepad
+ * button/d-pad outputs are always level (Regular). "Pulse" = a quick press+release in one update.
+ */
+sealed class Activator {
+ /** Press on down, release on up (default). */
+ object Regular : Activator()
+ /** Pulse only if pressed twice within [windowMs]; a single press does nothing. */
+ data class DoublePress(val windowMs: Long = 300) : Activator()
+ /** Press once held for [holdMs] (stays held until physical release). */
+ data class LongPress(val holdMs: Long = 500) : Activator()
+ /** While held, pulse every [intervalMs] (rapid fire). */
+ data class Turbo(val intervalMs: Long = 80) : Activator()
+ /** Fire (a quick pulse) on the **release** edge — Steam's `release` activator ("do X when you let go"). */
+ object OnRelease : Activator()
+}
+
+/**
+ * A digital binding: an [output] plus the [activator] press-logic that drives it. Steam per-binding settings
+ * (edge outputs only, for now): [delayStartMs]/[delayEndMs] = Fire Start/End Delay (ms; the output press/release
+ * is deferred, and "fires anyway" even if the button is released during the start delay); [toggle] = the press
+ * latches the output on, the next press latches it off.
+ */
+data class Binding(
+ val output: ScOutput,
+ val activator: Activator = Activator.Regular,
+ val delayStartMs: Long = 0,
+ val delayEndMs: Long = 0,
+ val toggle: Boolean = false,
+)
+
+/** Which XInput trigger axis a physical SC trigger's analog value drives. */
+enum class TriggerAxis { NONE, GAMEPAD_L2, GAMEPAD_R2 }
+
+/** What a physical trigger does. */
+sealed class TriggerMode {
+ /** Analog trigger -> an XInput trigger axis (the default). */
+ data class Axis(val axis: TriggerAxis) : TriggerMode()
+ /**
+ * Soft/full-pull staging: crossing [softThreshold] fires [soft], crossing [fullThreshold] fires [full]
+ * (both can be held; e.g. soft = aim, full = shoot). Optionally also drive an analog [axis].
+ */
+ data class Staged(
+ val soft: ScOutput,
+ val full: ScOutput,
+ val softThreshold: Float = 0.4f,
+ val fullThreshold: Float = 0.9f,
+ val axis: TriggerAxis = TriggerAxis.NONE,
+ ) : TriggerMode()
+}
+
+/** Analog thumbstick source mode. */
+sealed class StickMode {
+ object None : StickMode()
+ /** Drive a virtual XInput stick. [invertY] matches XInput's up-is-positive convention. [curve] shapes the
+ * magnitude response (linear/aggressive/relaxed). */
+ data class JoystickMove(
+ val stick: Stick,
+ val invertY: Boolean = true,
+ val deadzone: Float = 0.12f,
+ val curve: ResponseCurve = ResponseCurve.LINEAR,
+ ) : StickMode()
+ /** Stick → relative mouse (Steam `joystick_mouse`/`mouse_joystick`): deflection drives pointer velocity.
+ * [sensitivity] = px per update at full deflection; [deadzone] ignores rest jitter; [curve] shapes response. */
+ data class Mouse(
+ val sensitivity: Float = 12f,
+ val deadzone: Float = 0.10f,
+ val invertY: Boolean = false,
+ val curve: ResponseCurve = ResponseCurve.LINEAR,
+ ) : StickMode()
+ /** Flick stick (Steam `flickstick`): a simplified approximation — horizontal stick deflection → yaw mouse
+ * velocity. NOTE: not the true flick-and-rotate model; see docs/RISKS.md. [sensitivity] = px/update at full. */
+ data class FlickStick(val sensitivity: Float = 20f, val deadzone: Float = 0.20f) : StickMode()
+ /** Radial menu driven by the **stick** (e.g. ToME4 "Movement (Radial)"): deflection angle selects a slot;
+ * [activation] HOLD (default for sticks — hold the slot's output while pointed, like a movement radial) or
+ * COMMIT (pulse on return-to-center). The HUD shows while deflected past [deadzone], fades on center.
+ * [center] = the Steam radial's center button (`touch_menu_button_0`, render-only here); [directional] = a
+ * movement radial whose 8 ring slots are labelled by 8-way arrow (↑↗→↘↓↙←↖) instead of their bound key. */
+ data class RadialMenu(
+ val slots: List,
+ val activation: MenuActivation = MenuActivation.HOLD,
+ val deadzone: Float = 0.35f,
+ val center: MenuSlot? = null,
+ val directional: Boolean = false,
+ ) : StickMode()
+ /** Touch/grid menu driven by the stick: deflection picks a grid cell. [activation]/[deadzone] as [RadialMenu]. */
+ data class TouchMenu(val slots: List, val cols: Int, val rows: Int, val activation: MenuActivation = MenuActivation.HOLD, val deadzone: Float = 0.35f) : StickMode()
+ /** Stick as an 8-way d-pad (Steam stick `dpad`): deflection past [deadzone] presses the matching edge output(s)
+ * (diagonals press two); recenters to neutral. Same 8-way logic as [PadMode.DPad] but always live (no touch gate). */
+ data class DPad(
+ val up: ScOutput, val down: ScOutput, val left: ScOutput, val right: ScOutput,
+ val deadzone: Float = 0.35f,
+ val layout: DpadLayout = DpadLayout.EIGHT_WAY,
+ /** CROSS_GATE dead-diagonal band width, normalized 0..1 (Steam `overlap_region`/32768; def 4000 ≈ 0.122). */
+ val overlap: Float = 4000f / 32768f,
+ ) : StickMode()
+}
+
+/**
+ * How a Radial/Touch menu commits its highlighted slot.
+ * - [COMMIT]: select while interacting, fire (pulse) once when interaction ends (release/center) or on click —
+ * hotbar/quick-select menus.
+ * - [HOLD]: hold the highlighted slot's output while pointed; release when the highlight changes or interaction
+ * ends — movement radials ("hold a direction").
+ */
+enum class MenuActivation { COMMIT, HOLD }
+
+enum class Stick { LEFT, RIGHT }
+
+/** Steam d-pad `layout`: how deflection maps to the 4 directions.
+ * - [EIGHT_WAY] (0): each axis independent → diagonals press two directions (the default).
+ * - [FOUR_WAY] (1): only the dominant axis fires → no diagonals.
+ * - [ANALOG_EMU] (2): Steam emits an analog stick; our d-pad outputs are key/mouse edges, so there's nothing analog
+ * to emit → falls back to [EIGHT_WAY] (ponytail: no analog output path for key binds; revisit if a dpad→stick bind exists).
+ * - [CROSS_GATE] (3): cardinal only, with a dead diagonal band ([overlap] wide) so near-diagonals press nothing. */
+enum class DpadLayout { EIGHT_WAY, FOUR_WAY, ANALOG_EMU, CROSS_GATE;
+ companion object { fun fromVdf(v: Int) = when (v) { 1 -> FOUR_WAY; 2 -> ANALOG_EMU; 3 -> CROSS_GATE; else -> EIGHT_WAY } }
+}
+
+/** Response curve applied to an analog magnitude (0..1) before output. Approximates Steam's curve presets. */
+enum class ResponseCurve { LINEAR, AGGRESSIVE, RELAXED, WIDE, EXTRA_WIDE;
+ fun apply(m: Float): Float = when (this) {
+ LINEAR -> m
+ AGGRESSIVE -> m * m // slow near center, fast at edge
+ RELAXED -> kotlin.math.sqrt(m) // fast near center
+ WIDE -> m * m * m
+ EXTRA_WIDE -> m * 0.5f
+ }
+}
+
+/** Trackpad touch-motion source mode. (Pad CLICK is a separate digital bit handled via [ScProfile.buttons].) */
+sealed class PadMode {
+ object None : PadMode()
+ /**
+ * Relative mouse: finger drag -> pointer delta. [sensitivity] = pad-units-to-pixels divisor reciprocal.
+ * [jitterFloor] (raw pad units) gates out resting-finger noise so the cursor doesn't crawl when the finger is
+ * still; sub-[jitterFloor] per-report deltas are ignored. Sub-pixel motion is accumulated (not truncated away)
+ * so slow drags stay smooth. Raise [jitterFloor] if the cursor still wanders at rest; lower it if slow aiming
+ * feels dead.
+ */
+ data class Mouse(
+ val sensitivity: Float, val invertY: Boolean = true, val jitterFloor: Int = 24,
+ /** Rotate Output (Steam `rotation`, −180..180°): rotate the pointer-delta vector. */
+ val rotation: Float = 0f,
+ /** Per-axis output scale (Steam `sensitivity_horiz_scale`/`_vert_scale` %/100; the H/V Output Mixer). */
+ val horizScale: Float = 1f, val vertScale: Float = 1f,
+ /** Per-pad motion low-pass (0–100); was the global "Touchpad smoothing". Higher = smoother/laggier. */
+ val smoothing: Int = ScTuningStore.DEFAULT_SMOOTHING,
+ ) : PadMode()
+ /**
+ * Button Pad / ToME grid: pad split into [cols]×[rows]; the cell under the finger fires its output.
+ * [cells] is row-major, **row 0 = BOTTOM, col 0 = LEFT** (`cells[row*cols + col]`); pad with fewer cells
+ * than `cols*rows` leaves the rest unbound. [onClick] = fire on pad CLICK (else on TOUCH). Cell outputs
+ * currently support Key and MouseButton (the ToME 4×4 use case).
+ */
+ data class ButtonPadGrid(
+ val cols: Int,
+ val rows: Int,
+ val cells: List,
+ val onClick: Boolean = false,
+ ) : PadMode()
+ /**
+ * Pad as an 8-way d-pad: while touched, the finger direction past [deadzone] presses the matching
+ * output(s) (diagonals press two). Outputs are edge-style (Key / MouseButton) — e.g. pad → WASD.
+ */
+ data class DPad(
+ val up: ScOutput,
+ val down: ScOutput,
+ val left: ScOutput,
+ val right: ScOutput,
+ val deadzone: Float = 0.35f,
+ val layout: DpadLayout = DpadLayout.EIGHT_WAY,
+ /** CROSS_GATE dead-diagonal band width, normalized 0..1 (Steam `overlap_region`/32768; def 4000 ≈ 0.122). */
+ val overlap: Float = 4000f / 32768f,
+ ) : PadMode()
+ /** Finger slide → scroll wheel: every [step] pad-units of vertical travel emits one wheel click. */
+ data class ScrollWheel(val step: Int = 6000, val invertY: Boolean = false) : PadMode()
+ /**
+ * Trackpad as a virtual thumbstick (Steam pad `joystick_move`): the finger's absolute position on the pad =
+ * stick deflection while touched, recentering (zero) on lift. Reuses the stick joystick math ([deadzone]/
+ * [curve]/[invertY]); [stick] selects which XInput stick to drive.
+ */
+ data class Joystick(
+ val stick: Stick,
+ val invertY: Boolean = true,
+ val deadzone: Float = 0.12f,
+ val curve: ResponseCurve = ResponseCurve.LINEAR,
+ ) : PadMode()
+ /**
+ * Radial Menu (Steam `radial_menu`): while the pad is touched, the finger **angle** highlights one of
+ * [slots] arranged in a ring (slot 0 at top/12-o'clock, clockwise); committing fires that slot's binding as
+ * a pulse. [onClick] = commit on pad click; else commit on touch-release ("point and release"). The visual
+ * ring is the step-6 overlay; this is the source-independent selection logic (works "blind"). Slot labels are
+ * kept for the overlay. [center] = Steam's `touch_menu_button_0` center button (render-only); [directional] =
+ * a movement radial whose 8 ring slots are labelled by 8-way arrow instead of their bound key.
+ */
+ data class RadialMenu(
+ val slots: List,
+ val onClick: Boolean = false,
+ val activation: MenuActivation = MenuActivation.COMMIT,
+ val center: MenuSlot? = null,
+ val directional: Boolean = false,
+ ) : PadMode()
+ /**
+ * Touch Menu (Steam `touch_menu`): the pad is split into a [cols]×[rows] grid (row 0 = TOP), the cell under
+ * the finger highlights a slot, committing fires it. [onClick]/release semantics as [RadialMenu]. Same grid
+ * the overlay (step 6) will draw.
+ */
+ data class TouchMenu(val slots: List, val cols: Int, val rows: Int, val onClick: Boolean = false, val activation: MenuActivation = MenuActivation.COMMIT) : PadMode()
+ /**
+ * Absolute / region mouse (Steam `absolute_mouse` / `mouse_region`): while the pad is touched, the finger's
+ * absolute position maps 1:1 onto a screen [region] rectangle (normalized 0..1; the full screen = the whole
+ * desktop), warping the cursor there — unlike relative [Mouse], the cursor tracks WHERE the finger is, not how
+ * far it slid. The interpreter emits a normalized target ([ScOutputSink.mouseMoveAbs]); the sink scales it to
+ * the X screen geometry. [left]/[top]/[right]/[bottom] bound the region (default = full screen). [invertY]
+ * flips vertical (default: finger-up → cursor-up).
+ */
+ data class AbsoluteMouse(
+ /** Region CENTER as a screen fraction (Steam `position_x/y` %/100; 0.5 = centered). */
+ val centerX: Float = 0.5f, val centerY: Float = 0.5f,
+ /** Region SIZE as a screen fraction (Steam `scale` %/100 × `scale_x/y` %/100; full screen = 1.0). */
+ val sizeX: Float = 1f, val sizeY: Float = 1f,
+ val invertX: Boolean = false, val invertY: Boolean = false,
+ /** Rotate Output (Steam `rotation`, −180..180°): rotates the region-relative position vector before mapping. */
+ val rotation: Float = 0f,
+ ) : PadMode()
+ /**
+ * Mouse Joystick (Steam pad `mouse_joystick`): the pad acts like a self-centering joystick that drives the
+ * MOUSE — the finger's displacement from the pad CENTER sets a cursor velocity (further out = faster), zero at
+ * center, stops on lift. Unlike relative [Mouse] (drag = delta) it keeps moving while the finger is held off-centre.
+ */
+ data class MouseJoystick(val sensitivity: Float = 20f, val deadzone: Float = 0.10f, val invertY: Boolean = false) : PadMode()
+ /**
+ * Single-button pad (Steam `single_button`): touching/clicking anywhere on the pad fires [output]. No cursor,
+ * no menu — the whole surface is one button. [onClick] = fire on pad CLICK (else on TOUCH).
+ */
+ data class SingleButton(val output: ScOutput, val onClick: Boolean = false) : PadMode()
+ /**
+ * Directional swipe (Steam `2dscroll`): a quick flick in a cardinal direction pulses that direction's output.
+ * Unlike [DPad] (held while the finger stays deflected), a swipe fires once per flick past [threshold] then
+ * requires re-centering. Outputs are edge-style (Key/MouseButton/GamepadButton). [scrollMode] limits which axes
+ * are active (BOTH/HORIZONTAL/VERTICAL).
+ */
+ data class DirectionalSwipe(
+ val up: ScOutput, val down: ScOutput, val left: ScOutput, val right: ScOutput,
+ val threshold: Int = 8000, val scrollMode: SwipeAxes = SwipeAxes.BOTH,
+ ) : PadMode()
+ // Future: Trackball is an AbsoluteMouse/Mouse toggle, not a separate mode (see docs/STEAM-VDF-ADVANCED-SCHEMA.md).
+}
+
+/** Which axes a [PadMode.DirectionalSwipe] responds to (Steam scroll-wheel-mode). */
+enum class SwipeAxes { BOTH, HORIZONTAL, VERTICAL }
+
+/** One entry of a Radial/Touch menu: the [binding] it fires on commit + a [label] (for the step-6 overlay). */
+data class MenuSlot(val binding: Binding, val label: String = "")
+
+/** Gyro source mode. [invertX]/[invertY] flip each axis from the natural default (yaw-right→aim-right,
+ * pitch-up→aim-up); the natural default was chosen after on-device feedback that the raw sign felt backwards. */
+sealed class GyroMode {
+ object None : GyroMode()
+ /** Gyro rate -> mouse aim delta, gated by [gate] (e.g. only while a grip is held). [activation] = what the gate
+ * button DOES (hold-to-enable / hold-to-suppress / press-to-toggle). Feel set (all in RAW gyro-rate units, tuned
+ * on-device): [speedDeadzone] = rotation speed below which there's no output (kills hand shake); [precisionSpeed]
+ * = below this speed sensitivity scales down proportionally (fine aim); [accel] scales sensitivity UP with speed
+ * (fast flicks turn further); [hvMixer] −1..+1 rebalances horizontal vs vertical (>0 reduces horizontal, <0
+ * reduces vertical, 0 = 1:1). */
+ data class Mouse(
+ val sensitivity: Float, val gate: GyroGate = GyroGate.EITHER_GRIP,
+ val invertX: Boolean = false, val invertY: Boolean = false,
+ val activation: GyroActivation = GyroActivation.ENABLE,
+ val speedDeadzone: Float = 0f, val precisionSpeed: Float = 0f,
+ val accel: GyroAccel = GyroAccel.OFF, val hvMixer: Float = 0f,
+ ) : GyroMode()
+ /** Gyro -> virtual XInput stick. Two Steam styles, distinguished by [deflection]:
+ * - **camera** (`gyro_to_joystick_camera`, [deflection]=false): gyro *rate* → stick deflection (fast spin =
+ * far push, stops at rest — velocity-like aim for stick-look games). Yaw→X, pitch→Y.
+ * - **deflection** (`gyro_to_joystick_deflection`, [deflection]=true): gyro *angle* (integrated while gated) →
+ * *held* stick position — tilt to an angle and the stick stays there until you rotate back. The accumulated
+ * angle resets to center when the gate closes (ratchet), so integration drift can't build up.
+ * Both scaled by [sensitivity], gated by [gate], output to [stick]. [activation] = what the gate button DOES.
+ * Output shaping (Steam camera/deflection settings): [powerCurve] (0.1 aggressive … 1 linear … 4 relaxed) shapes
+ * the stick magnitude; [outputMin]/[outputMax] (0..1) rescale the output range; [lockAtEdges] clamps the combined
+ * magnitude to [outputMax] (else axes reach it independently → diagonal overshoot).
+ * (Lean Left/Right roll-axis binds are deferred — need a real bound export for the vdf input name + editor UI.) */
+ data class Joystick(
+ val stick: Stick = Stick.RIGHT, val sensitivity: Float,
+ val gate: GyroGate = GyroGate.EITHER_GRIP, val invertX: Boolean = false, val invertY: Boolean = false,
+ val deflection: Boolean = false, val activation: GyroActivation = GyroActivation.ENABLE,
+ val powerCurve: Float = 1f, val outputMin: Float = 0f, val outputMax: Float = 1f, val lockAtEdges: Boolean = false,
+ ) : GyroMode()
+}
+
+/** When a gyro mode is active. Grips are the rear paddles; the pad/stick-touch gates enable "touch-to-aim" (gyro
+ * only while the aim surface is touched — the most common gyro-mouse style). */
+enum class GyroGate {
+ ALWAYS, LEFT_GRIP, RIGHT_GRIP, EITHER_GRIP, LEFT_PAD_TOUCH, RIGHT_PAD_TOUCH, LEFT_STICK_TOUCH, RIGHT_STICK_TOUCH,
+ /** Gyro active while ANY trackpad OR thumbstick is touched (Steam `gyro_ratchet_button_mask` covering the four
+ * touch surfaces, require-any). The common "touch anything to aim" gate. */
+ ANY_TOUCH,
+ /** Gyro active only while ALL four touch surfaces are touched at once (mask covering them, require-all). */
+ ALL_TOUCH,
+ // Single-button gates — any button can gate the gyro, not just grips (the rear paddles + bumpers are the common
+ // picks; extend as needed). ponytail: single buttons only; arbitrary multi-button CHORDS need a mask-based gate
+ // (a `gateMask`/multi-select follow-up) — this covers the common "hold/toggle on " case.
+ L4, L5, R4, R5, LEFT_BUMPER, RIGHT_BUMPER, A, B, X, Y, L3, R3;
+}
+
+/** What the gyro gate button(s) DO (Steam "Gyro Enable/Suppress/Toggle", vdf `gyro_button`):
+ * - [ENABLE]: gyro is active *while* the gate is held (the default; a plain hold-to-aim grip).
+ * - [SUPPRESS]: gyro is active *unless* the gate is held (on by default, hold to turn OFF for a precise moment).
+ * - [TOGGLE]: each gate *press* flips gyro on/off (hands-free aim + easy ratcheting — toggle off, recenter, toggle on).
+ * With gate = [GyroGate.ALWAYS] there is no button, so [activation] is moot (gyro is simply always on). */
+enum class GyroActivation { ENABLE, SUPPRESS, TOGGLE }
+
+/** Gyro output acceleration (Steam Acceleration: Off / Linear / Relaxed / Aggressive) — scales sensitivity UP with
+ * rotation speed so quick flicks turn further. [gain] is applied as `1 + gain * (speed / ACCEL_REF)` (clamped),
+ * ACCEL_REF being a raw-speed reference tuned on-device. */
+enum class GyroAccel(val gain: Float) { OFF(0f), LINEAR(1f), RELAXED(0.5f), AGGRESSIVE(2f) }
+
+/**
+ * Trackpad/haptic feel parameters — profile-driven so the binding-editor UI can expose full haptics control
+ * (docs/STEAM-INPUT-FEATURES.md §8). Defaults are the values tuned-by-feel from the USBPcap capture and
+ * confirmed over USB on-phone 2026-06-17.
+ */
+data class HapticSettings(
+ val enabled: Boolean = true,
+ val leftPadEnabled: Boolean = true,
+ val rightPadEnabled: Boolean = true,
+ val clickGain: Int = 0xFE, // press-down click loudness (int8 dB; less-negative = louder)
+ val tickGain: Int = 0xF7, // slide detent loudness
+ val detentStep: Int = 7600, // pad-units of travel between detent ticks
+ val moveNoise: Int = 220, // per-report delta below this is jitter (ignored)
+)
+
+/**
+ * A full mapping profile. Digital bits map through [buttons]; the analog sources have dedicated mode fields.
+ * One action set for now; action sets/layers/mode-shift (build step 3) will wrap this.
+ */
+class ScProfile(
+ val name: String = "Default",
+ /** TritonProtocol.BTN_* bit -> binding (output + activator). Bits absent from the map are unbound. */
+ val buttons: Map = emptyMap(),
+ val leftStick: StickMode = StickMode.None,
+ val rightStick: StickMode = StickMode.None,
+ val leftPad: PadMode = PadMode.None,
+ val rightPad: PadMode = PadMode.None,
+ /** What each physical trigger does (analog axis by default; or soft/full staging). */
+ val leftTrigger: TriggerMode = TriggerMode.Axis(TriggerAxis.GAMEPAD_L2),
+ val rightTrigger: TriggerMode = TriggerMode.Axis(TriggerAxis.GAMEPAD_R2),
+ val gyro: GyroMode = GyroMode.None,
+ val haptics: HapticSettings = HapticSettings(),
+) {
+ companion object {
+ /**
+ * The shipped default profile (any game) — a faithful re-expression of the old hardcoded TritonMapper
+ * map. Sticks/ABXY/d-pad/bumpers/L3/R3/Start/Back/triggers -> virtual XInput pad; right pad drag ->
+ * mouse; right-pad click -> left mouse, left-pad click -> right mouse; gyro while a grip is held ->
+ * mouse aim; 4 rear paddles -> F1-F4 placeholders. Start/Back per SDL (VIEW 0x40=Start, MENU 0x4000=Back).
+ */
+ fun default(): ScProfile {
+ val b = TritonProtocol
+ val buttons = mapOf(
+ b.BTN_A to ScOutput.GamepadButton(ExternalController.IDX_BUTTON_A.toInt()),
+ b.BTN_B to ScOutput.GamepadButton(ExternalController.IDX_BUTTON_B.toInt()),
+ b.BTN_X to ScOutput.GamepadButton(ExternalController.IDX_BUTTON_X.toInt()),
+ b.BTN_Y to ScOutput.GamepadButton(ExternalController.IDX_BUTTON_Y.toInt()),
+ b.BTN_LBUMPER to ScOutput.GamepadButton(ExternalController.IDX_BUTTON_L1.toInt()),
+ b.BTN_RBUMPER to ScOutput.GamepadButton(ExternalController.IDX_BUTTON_R1.toInt()),
+ b.BTN_L3 to ScOutput.GamepadButton(ExternalController.IDX_BUTTON_L3.toInt()),
+ b.BTN_R3 to ScOutput.GamepadButton(ExternalController.IDX_BUTTON_R3.toInt()),
+ // SDL: VIEW bit (0x40) = Start, MENU bit (0x4000) = Back/Select.
+ b.BTN_VIEW to ScOutput.GamepadButton(ExternalController.IDX_BUTTON_START.toInt()),
+ b.BTN_MENU to ScOutput.GamepadButton(ExternalController.IDX_BUTTON_SELECT.toInt()),
+ b.BTN_DPAD_UP to ScOutput.GamepadDpad(0),
+ b.BTN_DPAD_RIGHT to ScOutput.GamepadDpad(1),
+ b.BTN_DPAD_DOWN to ScOutput.GamepadDpad(2),
+ b.BTN_DPAD_LEFT to ScOutput.GamepadDpad(3),
+ b.BTN_LTRIG_CLICK to ScOutput.GamepadButton(ExternalController.IDX_BUTTON_L2.toInt()),
+ b.BTN_RTRIG_CLICK to ScOutput.GamepadButton(ExternalController.IDX_BUTTON_R2.toInt()),
+ b.BTN_RPAD_CLICK to ScOutput.MouseButton(Pointer.Button.BUTTON_LEFT),
+ b.BTN_LPAD_CLICK to ScOutput.MouseButton(Pointer.Button.BUTTON_RIGHT),
+ // Rear paddles -> placeholder keys (profile-configurable later).
+ b.BTN_R4 to ScOutput.Key(XKeycode.KEY_F1),
+ b.BTN_R5 to ScOutput.Key(XKeycode.KEY_F2),
+ b.BTN_L4 to ScOutput.Key(XKeycode.KEY_F3),
+ b.BTN_L5 to ScOutput.Key(XKeycode.KEY_F4),
+ // Steam/Guide button opens the GameNative QuickMenu (and lets the controller navigate it).
+ b.BTN_STEAM to ScOutput.OpenQuickMenu,
+ // The "..." Quick-Access (3-dots) button between the trackpads toggles the on-screen keyboard.
+ b.BTN_QAM to ScOutput.ShowKeyboard,
+ ).mapValues { (_, out) -> Binding(out) } // default: all Regular activators
+ return ScProfile(
+ name = "Default",
+ buttons = buttons,
+ leftStick = StickMode.JoystickMove(Stick.LEFT, invertY = true, deadzone = 0.12f),
+ rightStick = StickMode.JoystickMove(Stick.RIGHT, invertY = true, deadzone = 0.12f),
+ leftPad = PadMode.None,
+ rightPad = PadMode.Mouse(sensitivity = 1.0f / 70f, invertY = true),
+ leftTrigger = TriggerMode.Axis(TriggerAxis.GAMEPAD_L2),
+ rightTrigger = TriggerMode.Axis(TriggerAxis.GAMEPAD_R2),
+ gyro = GyroMode.Mouse(sensitivity = 1.0f / 900f, gate = GyroGate.EITHER_GRIP),
+ haptics = HapticSettings(),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/ScProfileEditor.kt b/app/src/main/java/app/gamenative/steamcontroller/ScProfileEditor.kt
new file mode 100644
index 0000000000..f0e128d943
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/ScProfileEditor.kt
@@ -0,0 +1,661 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.inputcontrols.ExternalController
+import com.winlator.xserver.Pointer
+import com.winlator.xserver.XKeycode
+import kotlinx.serialization.Serializable
+import kotlin.math.roundToInt
+
+/**
+ * Editor-facing, JSON-serializable representation of a Steam Controller mapping, plus its converter to the
+ * runtime [ScProfile]. This is the data layer behind the in-app SC binding editor (newprompt "Next focus" #1b).
+ *
+ * Why a separate DTO instead of serializing [ScProfile] directly: [ScProfile] is built from sealed runtime
+ * types ([ScOutput]/[PadMode]/[StickMode]/…) that would each need polymorphic serialization wiring. The editor
+ * only authors **digital button bindings** for now (source → output + activator); the analog sources
+ * (sticks/pads/triggers/gyro) are inherited from a base profile ([ScProfile.default] by default). Keeping the
+ * DTO flat and string-keyed makes it trivially serializable and stable across versions.
+ */
+
+/** A bindable digital source the editor exposes, with its [TritonProtocol] bit and a human label. */
+enum class ScSource(val bit: Int, val label: String, val group: String) {
+ A(TritonProtocol.BTN_A, "A", "Face"),
+ B(TritonProtocol.BTN_B, "B", "Face"),
+ X(TritonProtocol.BTN_X, "X", "Face"),
+ Y(TritonProtocol.BTN_Y, "Y", "Face"),
+ LEFT_BUMPER(TritonProtocol.BTN_LBUMPER, "Left Bumper (L1)", "Bumpers"),
+ RIGHT_BUMPER(TritonProtocol.BTN_RBUMPER, "Right Bumper (R1)", "Bumpers"),
+ // Trigger full-pull digital clicks are folded into the Left/Right Trigger behavior editors (not shown as
+ // standalone button rows), mirroring how stick/pad clicks fold into their analog surface. Group unused.
+ LEFT_TRIGGER_CLICK(TritonProtocol.BTN_LTRIG_CLICK, "Left Trigger (full pull)", "Bumpers"),
+ RIGHT_TRIGGER_CLICK(TritonProtocol.BTN_RTRIG_CLICK, "Right Trigger (full pull)", "Bumpers"),
+ DPAD_UP(TritonProtocol.BTN_DPAD_UP, "D-Pad Up", "D-Pad"),
+ DPAD_DOWN(TritonProtocol.BTN_DPAD_DOWN, "D-Pad Down", "D-Pad"),
+ DPAD_LEFT(TritonProtocol.BTN_DPAD_LEFT, "D-Pad Left", "D-Pad"),
+ DPAD_RIGHT(TritonProtocol.BTN_DPAD_RIGHT, "D-Pad Right", "D-Pad"),
+ LEFT_STICK_CLICK(TritonProtocol.BTN_L3, "Left Stick Click (L3)", "Sticks/Pads"),
+ RIGHT_STICK_CLICK(TritonProtocol.BTN_R3, "Right Stick Click (R3)", "Sticks/Pads"),
+ LEFT_PAD_CLICK(TritonProtocol.BTN_LPAD_CLICK, "Left Pad Click", "Sticks/Pads"),
+ RIGHT_PAD_CLICK(TritonProtocol.BTN_RPAD_CLICK, "Right Pad Click", "Sticks/Pads"),
+ START(TritonProtocol.BTN_VIEW, "Start", "Menu"),
+ BACK(TritonProtocol.BTN_MENU, "Back / Select", "Menu"),
+ REAR_RIGHT_TOP(TritonProtocol.BTN_R4, "Rear Paddle R4", "Rear Paddles"),
+ REAR_RIGHT_BOTTOM(TritonProtocol.BTN_R5, "Rear Paddle R5", "Rear Paddles"),
+ REAR_LEFT_TOP(TritonProtocol.BTN_L4, "Rear Paddle L4", "Rear Paddles"),
+ REAR_LEFT_BOTTOM(TritonProtocol.BTN_L5, "Rear Paddle L5", "Rear Paddles"),
+ LEFT_GRIP(TritonProtocol.BTN_LGRIP, "Left Grip", "Grips"),
+ RIGHT_GRIP(TritonProtocol.BTN_RGRIP, "Right Grip", "Grips"),
+}
+
+/** What kind of output a binding emits (selects which DTO fields are meaningful).
+ * [INHERIT] = "keep whatever the base profile binds for this source" — used to preserve outputs the editor can't
+ * yet author (layer ops / mode-shift / mouse-nudge / show-keyboard / open-QuickMenu) so editing a config doesn't
+ * flatten them to NONE (an explicit unbind). Distinct from [NONE], which is a user-chosen unbind. */
+enum class OutputKind {
+ NONE, INHERIT, KEY, GAMEPAD_BUTTON, GAMEPAD_DPAD, MOUSE_BUTTON, SWITCH_ACTION_SET, MOUSE_NUDGE,
+ LAYER_OP, SHOW_KEYBOARD, OPEN_QUICK_MENU,
+}
+
+/** Editor-facing activator choices (map 1:1 to [Activator]). [DOUBLE_PRESS]/[LONG_PRESS]/[TURBO] carry a timing
+ * via [EditBinding.activatorMs]; [RELEASE] = Steam's release activator (fire on let-go). */
+enum class EditActivator { REGULAR, DOUBLE_PRESS, LONG_PRESS, TURBO, RELEASE }
+
+/** One source's binding in editor form. Only the field(s) relevant to [kind] are used. */
+@Serializable
+data class EditBinding(
+ val kind: OutputKind = OutputKind.NONE,
+ /** XKeycode names (a combo for KEY; one element for a single key). */
+ val keys: List = emptyList(),
+ /** ExternalController.IDX_BUTTON_* index for GAMEPAD_BUTTON. */
+ val gamepadIdx: Int = -1,
+ /** 0=up,1=right,2=down,3=left for GAMEPAD_DPAD. */
+ val dpadIndex: Int = -1,
+ /** Pointer.Button name for MOUSE_BUTTON. */
+ val mouseButton: String = "",
+ /** Target action-set id for SWITCH_ACTION_SET (an [ScEditableSet.id]). */
+ val targetSetId: String = "",
+ /** One-shot relative mouse nudge (MOUSE_NUDGE) — e.g. a radial's no-op `mouse_delta 0 0` center. */
+ val nudgeDx: Int = 0,
+ val nudgeDy: Int = 0,
+ /** LAYER_OP: target layer set id + the op (ADD/HOLD/REMOVE, a [LayerOpType] name). */
+ val layerId: String = "",
+ val layerOp: String = "HOLD",
+ val activator: EditActivator = EditActivator.REGULAR,
+ /** Activator timing in ms: double-press window / long-press hold / turbo interval. 0 = engine default. */
+ val activatorMs: Int = 0,
+ /** Display-only summary of an [OutputKind.INHERIT] binding (what advanced output is being preserved). */
+ val inheritDesc: String = "",
+) {
+ fun toOutput(): ScOutput = when (kind) {
+ OutputKind.NONE -> ScOutput.None
+ // INHERIT carries no output of its own — callers ([ScEditableProfile.toScProfile]) skip it so the base's
+ // binding survives untouched. Returning None here is purely defensive (it should never be reached).
+ OutputKind.INHERIT -> ScOutput.None
+ OutputKind.KEY -> ScOutput.Key(keys.mapNotNull { runCatching { XKeycode.valueOf(it) }.getOrNull() })
+ .let { if (it.keys.isEmpty()) ScOutput.None else it }
+ OutputKind.GAMEPAD_BUTTON -> if (gamepadIdx >= 0) ScOutput.GamepadButton(gamepadIdx) else ScOutput.None
+ OutputKind.GAMEPAD_DPAD -> if (dpadIndex in 0..3) ScOutput.GamepadDpad(dpadIndex) else ScOutput.None
+ OutputKind.MOUSE_BUTTON ->
+ runCatching { ScOutput.MouseButton(Pointer.Button.valueOf(mouseButton)) }.getOrDefault(ScOutput.None)
+ // A switch fires on press by default; the "On release" activator makes it fire on release instead — the
+ // second half of a momentary hold-to-shift (set A: press → set B; set B: release → back to set A).
+ OutputKind.SWITCH_ACTION_SET ->
+ if (targetSetId.isNotBlank()) ScOutput.SwitchActionSet(targetSetId, onRelease = activator == EditActivator.RELEASE)
+ else ScOutput.None
+ OutputKind.MOUSE_NUDGE -> ScOutput.MouseNudge(nudgeDx, nudgeDy)
+ OutputKind.SHOW_KEYBOARD -> ScOutput.ShowKeyboard
+ OutputKind.OPEN_QUICK_MENU -> ScOutput.OpenQuickMenu
+ OutputKind.LAYER_OP ->
+ if (layerId.isNotBlank()) ScOutput.LayerOp(layerId, runCatching { LayerOpType.valueOf(layerOp) }.getOrDefault(LayerOpType.HOLD))
+ else ScOutput.None
+ }
+
+ fun toActivator(): Activator = when (activator) {
+ EditActivator.REGULAR -> Activator.Regular
+ EditActivator.DOUBLE_PRESS -> Activator.DoublePress(if (activatorMs > 0) activatorMs.toLong() else 300)
+ EditActivator.LONG_PRESS -> Activator.LongPress(if (activatorMs > 0) activatorMs.toLong() else 500)
+ EditActivator.TURBO -> Activator.Turbo(if (activatorMs > 0) activatorMs.toLong() else 80)
+ EditActivator.RELEASE -> Activator.OnRelease
+ }
+
+ companion object {
+ /** Build an [EditBinding] from a bare runtime output (no activator) — used to seed D-Pad / staged-trigger
+ * outputs. A bare [Binding] carries the default [Activator.Regular], so this is exactly [Binding.toEdit]. */
+ fun fromOutput(out: ScOutput): EditBinding = Binding(out).toEdit()
+ }
+}
+
+/** A short human description of an advanced [ScOutput] the editor can't author yet (null = editor-authorable). Used
+ * to label [OutputKind.INHERIT] bindings so a preserved layer/mode-shift binding doesn't read as "Unbound". */
+fun ScOutput.advancedDesc(): String? = when (this) {
+ is ScOutput.ModeShift -> "Mode-shift $source"
+ is ScOutput.MousePosition -> "Mouse position"
+ is ScOutput.Macro -> "Macro (${commands.size} cmd${if (commands.size == 1) "" else "s"})"
+ else -> null
+}
+
+/** Convert a runtime [Binding] to its editor form (output + activator). Advanced outputs the editor can't author
+ * become [OutputKind.INHERIT] (preserved, not flattened). Shared by button + menu-slot seeding. */
+fun Binding.toEdit(): EditBinding {
+ val (act, ms) = when (val a = activator) {
+ is Activator.DoublePress -> EditActivator.DOUBLE_PRESS to a.windowMs.toInt()
+ is Activator.LongPress -> EditActivator.LONG_PRESS to a.holdMs.toInt()
+ is Activator.Turbo -> EditActivator.TURBO to a.intervalMs.toInt()
+ is Activator.OnRelease -> EditActivator.RELEASE to 0
+ else -> EditActivator.REGULAR to 0
+ }
+ return when (val o = output) {
+ is ScOutput.Key -> EditBinding(OutputKind.KEY, keys = o.keys.map { it.name }, activator = act, activatorMs = ms)
+ is ScOutput.GamepadButton -> EditBinding(OutputKind.GAMEPAD_BUTTON, gamepadIdx = o.idx, activator = act, activatorMs = ms)
+ is ScOutput.GamepadDpad -> EditBinding(OutputKind.GAMEPAD_DPAD, dpadIndex = o.index, activator = act, activatorMs = ms)
+ is ScOutput.MouseButton -> EditBinding(OutputKind.MOUSE_BUTTON, mouseButton = o.button.name, activator = act, activatorMs = ms)
+ // A switch's press/release timing lives in the output's onRelease flag, not the runtime activator.
+ is ScOutput.SwitchActionSet -> EditBinding(
+ OutputKind.SWITCH_ACTION_SET, targetSetId = o.targetSetId,
+ activator = if (o.onRelease) EditActivator.RELEASE else EditActivator.REGULAR,
+ )
+ is ScOutput.MouseNudge -> EditBinding(OutputKind.MOUSE_NUDGE, nudgeDx = o.dx, nudgeDy = o.dy)
+ is ScOutput.ShowKeyboard -> EditBinding(OutputKind.SHOW_KEYBOARD)
+ is ScOutput.OpenQuickMenu -> EditBinding(OutputKind.OPEN_QUICK_MENU)
+ is ScOutput.LayerOp -> EditBinding(OutputKind.LAYER_OP, layerId = o.layerId, layerOp = o.op.name)
+ // Advanced outputs (layer/mode-shift/mouse-nudge/show-keyboard/open-QuickMenu) -> INHERIT so a
+ // round-trip through the editor preserves them rather than unbinding them.
+ else -> o.advancedDesc()?.let { EditBinding(OutputKind.INHERIT, inheritDesc = it) }
+ ?: EditBinding(OutputKind.NONE, activator = act, activatorMs = ms)
+ }
+}
+
+/** One slot of an authored radial/touch/button-pad menu: a [label] (HUD) + the [binding] it fires. */
+@Serializable
+data class EditMenuSlot(val label: String = "", val binding: EditBinding = EditBinding()) {
+ fun toMenuSlot(): MenuSlot = MenuSlot(Binding(binding.toOutput(), binding.toActivator()), label)
+
+ companion object {
+ /** Seed from a runtime [MenuSlot], or null if its output is advanced/unrepresentable (caller then inherits). */
+ fun fromOrNull(s: MenuSlot): EditMenuSlot? =
+ s.binding.toEdit().takeIf { it.kind != OutputKind.INHERIT }?.let { EditMenuSlot(s.label, it) }
+
+ /** Seed from a bare [ScOutput] (button-pad cells carry no activator), or null if unrepresentable. */
+ fun fromOutputOrNull(out: ScOutput): EditMenuSlot? =
+ Binding(out).toEdit().takeIf { it.kind != OutputKind.INHERIT }?.let { EditMenuSlot("", it) }
+ }
+}
+
+/** Editor-facing response-curve choice (maps to the runtime [ResponseCurve]). */
+@Serializable
+enum class EditCurve { LINEAR, AGGRESSIVE, RELAXED, WIDE;
+ fun toRuntime(): ResponseCurve = when (this) {
+ LINEAR -> ResponseCurve.LINEAR
+ AGGRESSIVE -> ResponseCurve.AGGRESSIVE
+ RELAXED -> ResponseCurve.RELAXED
+ WIDE -> ResponseCurve.WIDE
+ }
+ companion object {
+ fun from(c: ResponseCurve): EditCurve = when (c) {
+ ResponseCurve.AGGRESSIVE -> AGGRESSIVE
+ ResponseCurve.RELAXED -> RELAXED
+ ResponseCurve.WIDE, ResponseCurve.EXTRA_WIDE -> WIDE
+ else -> LINEAR
+ }
+ }
+}
+
+/** Parametric analog-surface behaviors the editor can author (Steam's per-surface "Behavior" dropdown, for the
+ * modes with simple settings). Menu/Button-Pad behaviors carry slot lists and are authored elsewhere
+ * (`ScMenuLabels` / `.vdf` import) — a surface left on one of those keeps its base mode (the editor shows
+ * `null` = inherit for it). */
+@Serializable
+enum class AnalogMode { NONE, MOUSE, JOYSTICK, FLICK_STICK, SCROLL_WHEEL, DPAD, RADIAL, TOUCH_MENU, BUTTON_PAD }
+
+/**
+ * Editor representation of one analog surface (trackpad or stick): a chosen [mode] + its settings. Convert to the
+ * runtime [PadMode] / [StickMode] with [toPadMode] / [toStickMode] (each returns null when [mode] isn't valid for
+ * that surface kind, so the caller keeps the base mode). Only the fields relevant to [mode] are used.
+ */
+@Serializable
+data class EditAnalog(
+ val mode: AnalogMode = AnalogMode.NONE,
+ /** Mouse / flick sensitivity as a percent of the engine default (100 = default). */
+ val sensitivityPct: Int = 100,
+ /** Deadzone as a percent of full deflection (stick modes / stick-mouse). */
+ val deadzonePct: Int = 12,
+ /** Pad MOUSE touch-feel (per-pad): motion smoothing (0–100) + rest jitter floor (raw pad units). */
+ val smoothingPct: Int = ScTuningStore.DEFAULT_SMOOTHING,
+ val jitterFloor: Int = 24,
+ val invertY: Boolean = false,
+ val curve: EditCurve = EditCurve.LINEAR,
+ /** JOYSTICK: which virtual XInput stick to drive ("LEFT"/"RIGHT"). */
+ val outputStick: String = "RIGHT",
+ /** SCROLL_WHEEL: pad-units of travel per wheel click. */
+ val scrollStep: Int = 6000,
+ /** MOUSE (relative pad): Rotate Output (deg) + per-axis H/V output scale (1.0 = 100%). */
+ val mouseRotation: Float = 0f,
+ val mouseHorizScale: Float = 1f,
+ val mouseVertScale: Float = 1f,
+ /** DPAD: the four directional outputs. */
+ val up: EditBinding = EditBinding(),
+ val down: EditBinding = EditBinding(),
+ val left: EditBinding = EditBinding(),
+ val right: EditBinding = EditBinding(),
+ /** DPAD: layout mode name (EIGHT_WAY / FOUR_WAY / ANALOG_EMU / CROSS_GATE) + normalized cross-gate band. */
+ val dpadLayout: String = "EIGHT_WAY",
+ val dpadOverlap: Float = 4000f / 32768f,
+ // ── Menu modes (RADIAL / TOUCH_MENU / BUTTON_PAD) ──
+ /** The menu's slots (ring order for RADIAL; row-major for TOUCH_MENU/BUTTON_PAD). */
+ val slots: List = emptyList(),
+ /** Grid dimensions for TOUCH_MENU / BUTTON_PAD (ignored by RADIAL). */
+ val menuCols: Int = 0,
+ val menuRows: Int = 0,
+ /** Commit on pad CLICK (else on touch/release). Pad menus only. */
+ val menuOnClick: Boolean = false,
+ /** true = HOLD activation (hold the slot while pointed), false = COMMIT (pulse on commit). */
+ val menuHold: Boolean = false,
+ /** RADIAL center button (`touch_menu_button_0`); null = none. */
+ val menuCenter: EditMenuSlot? = null,
+ /** RADIAL movement menu (8 ring slots labelled by arrows). */
+ val menuDirectional: Boolean = false,
+) {
+ private fun menuActivation() = if (menuHold) MenuActivation.HOLD else MenuActivation.COMMIT
+ private fun menuSlots() = slots.map { it.toMenuSlot() }
+
+ fun toPadMode(): PadMode? = when (mode) {
+ AnalogMode.NONE -> PadMode.None
+ AnalogMode.MOUSE -> PadMode.Mouse(sensitivity = DEFAULT_PAD_MOUSE_SENS * sensitivityPct / 100f, invertY = invertY,
+ jitterFloor = jitterFloor, smoothing = smoothingPct,
+ rotation = mouseRotation, horizScale = mouseHorizScale, vertScale = mouseVertScale)
+ AnalogMode.SCROLL_WHEEL -> PadMode.ScrollWheel(step = scrollStep, invertY = invertY)
+ AnalogMode.DPAD -> PadMode.DPad(up.toOutput(), down.toOutput(), left.toOutput(), right.toOutput(), deadzone = deadzonePct / 100f,
+ layout = runCatching { DpadLayout.valueOf(dpadLayout) }.getOrDefault(DpadLayout.EIGHT_WAY), overlap = dpadOverlap)
+ AnalogMode.RADIAL -> PadMode.RadialMenu(
+ slots = menuSlots(), onClick = menuOnClick, activation = menuActivation(),
+ center = menuCenter?.toMenuSlot(), directional = menuDirectional,
+ )
+ AnalogMode.TOUCH_MENU -> PadMode.TouchMenu(
+ slots = menuSlots(), cols = menuCols.coerceAtLeast(1), rows = menuRows.coerceAtLeast(1),
+ onClick = menuOnClick, activation = menuActivation(),
+ )
+ AnalogMode.BUTTON_PAD -> PadMode.ButtonPadGrid(
+ cols = menuCols.coerceAtLeast(1), rows = menuRows.coerceAtLeast(1),
+ cells = slots.map { it.binding.toOutput() }, onClick = menuOnClick,
+ )
+ else -> null // JOYSTICK / FLICK_STICK are stick-only
+ }
+
+ fun toStickMode(): StickMode? = when (mode) {
+ AnalogMode.NONE -> StickMode.None
+ AnalogMode.JOYSTICK -> StickMode.JoystickMove(
+ stick = if (outputStick == "LEFT") Stick.LEFT else Stick.RIGHT,
+ invertY = invertY, deadzone = deadzonePct / 100f, curve = curve.toRuntime(),
+ )
+ AnalogMode.MOUSE -> StickMode.Mouse(sensitivity = DEFAULT_STICK_MOUSE_SENS * sensitivityPct / 100f, deadzone = deadzonePct / 100f, invertY = invertY, curve = curve.toRuntime())
+ AnalogMode.FLICK_STICK -> StickMode.FlickStick(sensitivity = DEFAULT_FLICK_SENS * sensitivityPct / 100f, deadzone = deadzonePct / 100f)
+ AnalogMode.RADIAL -> StickMode.RadialMenu(
+ slots = menuSlots(), activation = menuActivation(), deadzone = deadzonePct / 100f,
+ center = menuCenter?.toMenuSlot(), directional = menuDirectional,
+ )
+ AnalogMode.TOUCH_MENU -> StickMode.TouchMenu(
+ slots = menuSlots(), cols = menuCols.coerceAtLeast(1), rows = menuRows.coerceAtLeast(1),
+ activation = menuActivation(), deadzone = deadzonePct / 100f,
+ )
+ else -> null // SCROLL_WHEEL / DPAD / BUTTON_PAD are pad-only
+ }
+
+ companion object {
+ const val DEFAULT_PAD_MOUSE_SENS = 1f / 70f
+ const val DEFAULT_STICK_MOUSE_SENS = 12f
+ const val DEFAULT_FLICK_SENS = 20f
+
+ /** Seed an [EditAnalog] from a runtime pad mode, or null if it's a mode the editor doesn't author yet
+ * (menus / button-pad) — null means "inherit / leave as-is", so saving won't clobber it. */
+ fun fromPad(m: PadMode): EditAnalog? = when (m) {
+ is PadMode.None -> EditAnalog(AnalogMode.NONE)
+ is PadMode.Mouse -> EditAnalog(AnalogMode.MOUSE, sensitivityPct = (m.sensitivity / DEFAULT_PAD_MOUSE_SENS * 100f).roundToInt(), invertY = m.invertY,
+ smoothingPct = m.smoothing, jitterFloor = m.jitterFloor,
+ mouseRotation = m.rotation, mouseHorizScale = m.horizScale, mouseVertScale = m.vertScale)
+ // Absolute/region mouse, single-button, directional-swipe aren't authored in the editor yet → null =
+ // inherit (preserved losslessly on edit; importer handles them).
+ is PadMode.AbsoluteMouse -> null
+ is PadMode.SingleButton -> null
+ is PadMode.DirectionalSwipe -> null
+ is PadMode.Joystick -> null
+ is PadMode.MouseJoystick -> null
+ is PadMode.ScrollWheel -> EditAnalog(AnalogMode.SCROLL_WHEEL, scrollStep = m.step, invertY = m.invertY)
+ is PadMode.DPad -> EditAnalog(
+ AnalogMode.DPAD, deadzonePct = (m.deadzone * 100f).roundToInt(),
+ up = EditBinding.fromOutput(m.up), down = EditBinding.fromOutput(m.down),
+ left = EditBinding.fromOutput(m.left), right = EditBinding.fromOutput(m.right),
+ dpadLayout = m.layout.name, dpadOverlap = m.overlap,
+ )
+ // Menus are representable only if every slot's output is editor-authorable; otherwise null = inherit the
+ // base surface untouched (keeps the overlay lossless for exotic menu bindings).
+ is PadMode.RadialMenu -> EditAnalog(
+ AnalogMode.RADIAL, slots = m.slots.map { EditMenuSlot.fromOrNull(it) ?: return null },
+ menuOnClick = m.onClick, menuHold = m.activation == MenuActivation.HOLD,
+ menuCenter = m.center?.let { EditMenuSlot.fromOrNull(it) ?: return null }, menuDirectional = m.directional,
+ )
+ is PadMode.TouchMenu -> EditAnalog(
+ AnalogMode.TOUCH_MENU, slots = m.slots.map { EditMenuSlot.fromOrNull(it) ?: return null },
+ menuCols = m.cols, menuRows = m.rows, menuOnClick = m.onClick, menuHold = m.activation == MenuActivation.HOLD,
+ )
+ is PadMode.ButtonPadGrid -> EditAnalog(
+ AnalogMode.BUTTON_PAD, slots = m.cells.map { EditMenuSlot.fromOutputOrNull(it) ?: return null },
+ menuCols = m.cols, menuRows = m.rows, menuOnClick = m.onClick,
+ )
+ }
+
+ fun fromStick(m: StickMode): EditAnalog? = when (m) {
+ is StickMode.None -> EditAnalog(AnalogMode.NONE)
+ is StickMode.JoystickMove -> EditAnalog(AnalogMode.JOYSTICK, deadzonePct = (m.deadzone * 100f).roundToInt(), invertY = m.invertY, curve = EditCurve.from(m.curve), outputStick = m.stick.name)
+ is StickMode.Mouse -> EditAnalog(AnalogMode.MOUSE, sensitivityPct = (m.sensitivity / DEFAULT_STICK_MOUSE_SENS * 100f).roundToInt(), deadzonePct = (m.deadzone * 100f).roundToInt(), invertY = m.invertY, curve = EditCurve.from(m.curve))
+ is StickMode.FlickStick -> EditAnalog(AnalogMode.FLICK_STICK, sensitivityPct = (m.sensitivity / DEFAULT_FLICK_SENS * 100f).roundToInt(), deadzonePct = (m.deadzone * 100f).roundToInt())
+ is StickMode.RadialMenu -> EditAnalog(
+ AnalogMode.RADIAL, slots = m.slots.map { EditMenuSlot.fromOrNull(it) ?: return null },
+ deadzonePct = (m.deadzone * 100f).roundToInt(), menuHold = m.activation == MenuActivation.HOLD,
+ menuCenter = m.center?.let { EditMenuSlot.fromOrNull(it) ?: return null }, menuDirectional = m.directional,
+ )
+ is StickMode.TouchMenu -> EditAnalog(
+ AnalogMode.TOUCH_MENU, slots = m.slots.map { EditMenuSlot.fromOrNull(it) ?: return null },
+ menuCols = m.cols, menuRows = m.rows, deadzonePct = (m.deadzone * 100f).roundToInt(),
+ menuHold = m.activation == MenuActivation.HOLD,
+ )
+ // Stick d-pad isn't a stick option in the editor (DPAD is pad-only) -> null = inherit (preserved losslessly).
+ is StickMode.DPad -> null
+ }
+ }
+}
+
+/** Editor representation of a physical trigger (Steam's trigger "Behavior"). AXIS = analog → an XInput trigger
+ * axis; STAGED = soft/full-pull staging (each stage fires a command at a pull threshold), optionally also an axis. */
+@Serializable
+enum class TriggerEditMode { AXIS, STAGED }
+
+@Serializable
+data class EditTrigger(
+ val mode: TriggerEditMode = TriggerEditMode.AXIS,
+ /** TriggerAxis name (NONE / GAMEPAD_L2 / GAMEPAD_R2). */
+ val axis: String = "GAMEPAD_R2",
+ val soft: EditBinding = EditBinding(),
+ val full: EditBinding = EditBinding(),
+ val softThresholdPct: Int = 40,
+ val fullThresholdPct: Int = 90,
+) {
+ private fun axisOr(default: TriggerAxis) = runCatching { TriggerAxis.valueOf(axis) }.getOrDefault(default)
+
+ fun toRuntime(defaultAxis: TriggerAxis): TriggerMode = when (mode) {
+ TriggerEditMode.AXIS -> TriggerMode.Axis(axisOr(defaultAxis))
+ TriggerEditMode.STAGED -> TriggerMode.Staged(
+ soft = soft.toOutput(), full = full.toOutput(),
+ softThreshold = softThresholdPct / 100f, fullThreshold = fullThresholdPct / 100f,
+ axis = axisOr(TriggerAxis.NONE),
+ )
+ }
+
+ companion object {
+ fun from(m: TriggerMode): EditTrigger = when (m) {
+ is TriggerMode.Axis -> EditTrigger(TriggerEditMode.AXIS, axis = m.axis.name)
+ is TriggerMode.Staged -> EditTrigger(
+ TriggerEditMode.STAGED, axis = m.axis.name,
+ soft = EditBinding.fromOutput(m.soft), full = EditBinding.fromOutput(m.full),
+ softThresholdPct = (m.softThreshold * 100f).roundToInt(), fullThresholdPct = (m.fullThreshold * 100f).roundToInt(),
+ )
+ }
+ }
+}
+
+/** Editor representation of the gyro (Steam's gyro "Behavior"). OFF = disabled; MOUSE = gyro rate → mouse aim,
+ * gated by [gate] (when the gyro is active — e.g. only while a grip is held). */
+@Serializable
+enum class GyroEditMode { OFF, MOUSE, JOYSTICK }
+
+@Serializable
+data class EditGyro(
+ val mode: GyroEditMode = GyroEditMode.MOUSE,
+ val sensitivityPct: Int = 100,
+ /** GyroGate name (ALWAYS / LEFT_GRIP / RIGHT_GRIP / EITHER_GRIP / LEFT_PAD_TOUCH / RIGHT_PAD_TOUCH). */
+ val gate: String = "EITHER_GRIP",
+ /** For JOYSTICK: which output stick (LEFT/RIGHT). */
+ val outputStick: String = "RIGHT",
+ /** For JOYSTICK: deflection style (angle→held position) vs the default camera style (rate→velocity). */
+ val deflection: Boolean = false,
+ /** GyroActivation name (ENABLE / SUPPRESS / TOGGLE) — what the gate button does. */
+ val activation: String = "ENABLE",
+ /** MOUSE feel set: speed deadzone + precision speed (raw gyro units), acceleration curve, H/V mixer (−100..100%). */
+ val speedDeadzone: Int = 0,
+ val precisionSpeed: Int = 0,
+ val accel: String = "OFF",
+ val hvMixerPct: Int = 0,
+ /** JOYSTICK shaping: power curve (×100 → 0.1..4), output range (%), lock at edges. */
+ val powerCurvePct: Int = 100,
+ val outputMinPct: Int = 0,
+ val outputMaxPct: Int = 100,
+ val lockAtEdges: Boolean = false,
+) {
+ private fun gateEnum() = runCatching { GyroGate.valueOf(gate) }.getOrDefault(GyroGate.EITHER_GRIP)
+ private fun activationEnum() = runCatching { GyroActivation.valueOf(activation) }.getOrDefault(GyroActivation.ENABLE)
+ private fun accelEnum() = runCatching { GyroAccel.valueOf(accel) }.getOrDefault(GyroAccel.OFF)
+ fun toRuntime(): GyroMode = when (mode) {
+ GyroEditMode.OFF -> GyroMode.None
+ GyroEditMode.MOUSE -> GyroMode.Mouse(
+ sensitivity = DEFAULT_GYRO_SENS * sensitivityPct / 100f, gate = gateEnum(), activation = activationEnum(),
+ speedDeadzone = speedDeadzone.toFloat(), precisionSpeed = precisionSpeed.toFloat(),
+ accel = accelEnum(), hvMixer = hvMixerPct / 100f,
+ )
+ GyroEditMode.JOYSTICK -> GyroMode.Joystick(
+ stick = runCatching { Stick.valueOf(outputStick) }.getOrDefault(Stick.RIGHT),
+ sensitivity = (if (deflection) DEFAULT_GYRO_DEFLECT_SENS else DEFAULT_GYRO_JOY_SENS) * sensitivityPct / 100f,
+ gate = gateEnum(), deflection = deflection, activation = activationEnum(),
+ powerCurve = (powerCurvePct / 100f).coerceIn(0.1f, 4f), outputMin = outputMinPct / 100f,
+ outputMax = outputMaxPct / 100f, lockAtEdges = lockAtEdges,
+ )
+ }
+
+ companion object {
+ const val DEFAULT_GYRO_SENS = 1f / 900f
+ const val DEFAULT_GYRO_JOY_SENS = 1f / 6000f // camera: gyro rate → stick deflection
+ const val DEFAULT_GYRO_DEFLECT_SENS = 1f / 60000f // deflection: integrated angle → held position
+ fun from(m: GyroMode): EditGyro = when (m) {
+ is GyroMode.Joystick -> EditGyro(GyroEditMode.JOYSTICK, sensitivityPct = (m.sensitivity / (if (m.deflection) DEFAULT_GYRO_DEFLECT_SENS else DEFAULT_GYRO_JOY_SENS) * 100f).roundToInt(), gate = m.gate.name, outputStick = m.stick.name, deflection = m.deflection, activation = m.activation.name,
+ powerCurvePct = (m.powerCurve * 100f).roundToInt(), outputMinPct = (m.outputMin * 100f).roundToInt(), outputMaxPct = (m.outputMax * 100f).roundToInt(), lockAtEdges = m.lockAtEdges)
+ is GyroMode.None -> EditGyro(GyroEditMode.OFF)
+ is GyroMode.Mouse -> EditGyro(GyroEditMode.MOUSE, sensitivityPct = (m.sensitivity / DEFAULT_GYRO_SENS * 100f).roundToInt(), gate = m.gate.name, activation = m.activation.name,
+ speedDeadzone = m.speedDeadzone.roundToInt(), precisionSpeed = m.precisionSpeed.roundToInt(), accel = m.accel.name, hvMixerPct = (m.hvMixer * 100f).roundToInt())
+ }
+ }
+}
+
+/** Editor representation of haptics: master + per-pad enable and the slide-detent spacing. (Gain dB values keep
+ * their tuned defaults — not exposed yet; see newprompt backlog "Haptics control UI".) */
+@Serializable
+data class EditHaptics(
+ val enabled: Boolean = true,
+ val leftPadEnabled: Boolean = true,
+ val rightPadEnabled: Boolean = true,
+ val detentStep: Int = 7600,
+) {
+ fun toRuntime(base: HapticSettings): HapticSettings =
+ base.copy(enabled = enabled, leftPadEnabled = leftPadEnabled, rightPadEnabled = rightPadEnabled, detentStep = detentStep)
+
+ companion object {
+ fun from(h: HapticSettings) = EditHaptics(h.enabled, h.leftPadEnabled, h.rightPadEnabled, h.detentStep)
+ }
+}
+
+/**
+ * A whole editable profile: a name plus per-[ScSource] bindings (keyed by [ScSource.name] so the JSON is
+ * stable). Sources absent from [buttons] inherit from the base profile at conversion time. The analog surfaces
+ * ([leftPad]/[rightPad]/[leftStick]/[rightStick]) plus [leftTrigger]/[rightTrigger]/[gyro]/[haptics] are
+ * null = inherit the base profile; set = override.
+ */
+@Serializable
+data class ScEditableProfile(
+ val name: String = "Custom",
+ val buttons: Map = emptyMap(),
+ val leftPad: EditAnalog? = null,
+ val rightPad: EditAnalog? = null,
+ val leftStick: EditAnalog? = null,
+ val rightStick: EditAnalog? = null,
+ val leftTrigger: EditTrigger? = null,
+ val rightTrigger: EditTrigger? = null,
+ val gyro: EditGyro? = null,
+ val haptics: EditHaptics? = null,
+) {
+ /**
+ * Build a runtime [ScProfile]: start from [base] (so analog sources keep sensible defaults), then override
+ * exactly the digital sources this editable profile binds. An [EditBinding] of kind NONE explicitly unbinds
+ * its source (removes it from the button map).
+ */
+ fun toScProfile(base: ScProfile = ScProfile.default()): ScProfile {
+ val overrides = HashMap(base.buttons)
+ for ((srcName, eb) in buttons) {
+ val src = runCatching { ScSource.valueOf(srcName) }.getOrNull() ?: continue
+ // INHERIT keeps the base's binding for this source (preserves layer/mode-shift/etc the editor can't author).
+ if (eb.kind == OutputKind.INHERIT) continue
+ val out = eb.toOutput()
+ if (out is ScOutput.None) overrides.remove(src.bit)
+ else overrides[src.bit] = Binding(out, eb.toActivator())
+ }
+ return ScProfile(
+ name = name,
+ buttons = overrides,
+ // Analog surfaces: a set EditAnalog overrides the base mode; null (or a mode invalid for that surface
+ // kind, e.g. a pad set to JOYSTICK) falls through to the base profile's mode.
+ leftStick = leftStick?.toStickMode() ?: base.leftStick,
+ rightStick = rightStick?.toStickMode() ?: base.rightStick,
+ leftPad = leftPad?.toPadMode() ?: base.leftPad,
+ rightPad = rightPad?.toPadMode() ?: base.rightPad,
+ leftTrigger = leftTrigger?.toRuntime(TriggerAxis.GAMEPAD_L2) ?: base.leftTrigger,
+ rightTrigger = rightTrigger?.toRuntime(TriggerAxis.GAMEPAD_R2) ?: base.rightTrigger,
+ gyro = gyro?.toRuntime() ?: base.gyro,
+ haptics = haptics?.toRuntime(base.haptics) ?: base.haptics,
+ )
+ }
+
+ /** Wrap as a single-action-set [ScConfig] (what the live path consumes). */
+ fun toScConfig(base: ScProfile = ScProfile.default()): ScConfig =
+ ScConfig(sets = mapOf("0" to toScProfile(base)), defaultSetId = "0")
+
+ /** The `group_source_bindings` source names this profile defines (a non-null analog surface) — used to derive a
+ * layer's [ScConfig.setSources] so it overrides exactly those surfaces ([mergeProfiles]). Names must match
+ * [mergeProfiles]'s checks. */
+ fun definedSources(): Set = buildSet {
+ if (leftStick != null) add("joystick")
+ if (rightStick != null) add("right_joystick")
+ if (leftPad != null) add("left_trackpad")
+ if (rightPad != null) add("right_trackpad")
+ if (leftTrigger != null) add("left_trigger")
+ if (rightTrigger != null) add("right_trigger")
+ if (gyro != null) add("gyro")
+ }
+
+ companion object {
+ /** Seed an editable profile from a runtime [ScProfile] (default = the shipped default) so the editor
+ * opens showing the current bindings rather than a blank slate. */
+ fun from(profile: ScProfile = ScProfile.default()): ScEditableProfile {
+ val byBit = profile.buttons
+ val map = LinkedHashMap()
+ for (src in ScSource.entries) {
+ val b = byBit[src.bit] ?: continue
+ map[src.name] = b.toEdit()
+ }
+ return ScEditableProfile(
+ name = profile.name,
+ buttons = map,
+ leftPad = EditAnalog.fromPad(profile.leftPad),
+ rightPad = EditAnalog.fromPad(profile.rightPad),
+ leftStick = EditAnalog.fromStick(profile.leftStick),
+ rightStick = EditAnalog.fromStick(profile.rightStick),
+ leftTrigger = EditTrigger.from(profile.leftTrigger),
+ rightTrigger = EditTrigger.from(profile.rightTrigger),
+ gyro = EditGyro.from(profile.gyro),
+ haptics = EditHaptics.from(profile.haptics),
+ )
+ }
+
+ /** Common XInput pad buttons offered in the editor (index → label). */
+ val GAMEPAD_BUTTONS: List> = listOf(
+ ExternalController.IDX_BUTTON_A.toInt() to "Pad A",
+ ExternalController.IDX_BUTTON_B.toInt() to "Pad B",
+ ExternalController.IDX_BUTTON_X.toInt() to "Pad X",
+ ExternalController.IDX_BUTTON_Y.toInt() to "Pad Y",
+ ExternalController.IDX_BUTTON_L1.toInt() to "Pad LB",
+ ExternalController.IDX_BUTTON_R1.toInt() to "Pad RB",
+ ExternalController.IDX_BUTTON_L2.toInt() to "Pad LT",
+ ExternalController.IDX_BUTTON_R2.toInt() to "Pad RT",
+ ExternalController.IDX_BUTTON_L3.toInt() to "Pad L3",
+ ExternalController.IDX_BUTTON_R3.toInt() to "Pad R3",
+ ExternalController.IDX_BUTTON_START.toInt() to "Pad Start",
+ ExternalController.IDX_BUTTON_SELECT.toInt() to "Pad Back",
+ )
+
+ val DPAD_DIRECTIONS: List> =
+ listOf(0 to "Pad D-Pad Up", 1 to "Pad D-Pad Right", 2 to "Pad D-Pad Down", 3 to "Pad D-Pad Left")
+
+ val MOUSE_BUTTONS: List =
+ listOf(Pointer.Button.BUTTON_LEFT.name, Pointer.Button.BUTTON_RIGHT.name, Pointer.Button.BUTTON_MIDDLE.name)
+ }
+}
+
+/** One authored action set: a stable [id] (used as the [ScConfig] set key + [ScOutput.SwitchActionSet] target), a
+ * user-facing [name], and its [profile]. */
+@Serializable
+data class ScEditableSet(
+ val id: String = "0",
+ val name: String = "Set",
+ val profile: ScEditableProfile = ScEditableProfile(),
+ /** True = this set is an action **layer** (a partial overlay pushed by [ScOutput.LayerOp]) rather than a base
+ * action set switched to by [ScOutput.SwitchActionSet]. Drives [ScConfig.setSources] derivation. */
+ val isLayer: Boolean = false,
+)
+
+/**
+ * Editor model for a whole multi-action-set config (Phase 5d). Holds an ordered list of [sets] plus which one is
+ * active at launch ([defaultSetId]). Converts to the runtime [ScConfig] the live path consumes. A single-set config
+ * is just a list of one — and [fromSingle] migrates the legacy single-[ScEditableProfile] storage transparently.
+ */
+@Serializable
+data class ScEditableConfig(
+ val sets: List = listOf(ScEditableSet(id = "0", name = "Default")),
+ val defaultSetId: String = "0",
+) {
+ fun toScConfig(base: ScProfile = ScProfile.default()): ScConfig {
+ val map = LinkedHashMap()
+ val sources = HashMap>()
+ for (s in sets) {
+ map[s.id] = s.profile.toScProfile(base)
+ // A layer overrides exactly the analog surfaces it defines (buttons always merge per-bit); derive that
+ // source set so [mergeProfiles] applies only those. Non-layer sets are switched-to (full replace), so
+ // they need no source list.
+ if (s.isLayer) sources[s.id] = s.profile.definedSources()
+ }
+ val def = if (sets.any { it.id == defaultSetId }) defaultSetId else (sets.firstOrNull()?.id ?: "0")
+ return ScConfig(sets = map, defaultSetId = def, setSources = sources)
+ }
+
+ /** Next free numeric set id (ids are just stable strings; we mint "0","1","2",… ). */
+ fun nextSetId(): String = (generateSequence(0) { it + 1 }.first { n -> sets.none { it.id == n.toString() } }).toString()
+
+ companion object {
+ /** Wrap a legacy single editable profile as a one-set config (back-compat with the old `.json`). */
+ fun fromSingle(p: ScEditableProfile): ScEditableConfig =
+ ScEditableConfig(sets = listOf(ScEditableSet(id = "0", name = p.name.ifBlank { "Default" }, profile = p)))
+
+ /**
+ * Seed an editable config from a resolved runtime [ScConfig] (e.g. a parsed `.vdf`) so the editor opens
+ * showing that config's action sets + bindings. Advanced button outputs the editor can't author yet
+ * (layer ops / mode-shift / etc) seed as [OutputKind.INHERIT] (preserved, not flattened); menu/radial/
+ * button-pad analog surfaces seed as null=inherit; and config-level layer-source / mode-shift-overlay
+ * structures are NOT carried on the editable model. For a `.vdf`-active config the lossless path is
+ * base-vdf + overlay ([ScConfigStore.saveEditableConfig]/`resolve`), which resolves this against the parsed
+ * vdf as base so every inherited surface (menus, layers, mode-shift) survives editing exactly.
+ */
+ fun fromScConfig(cfg: ScConfig): ScEditableConfig {
+ val sets = cfg.sets.entries.map { (id, profile) ->
+ ScEditableSet(id = id, name = profile.name.ifBlank { "Set $id" }, profile = ScEditableProfile.from(profile))
+ }
+ return ScEditableConfig(
+ sets = sets.ifEmpty { listOf(ScEditableSet(id = "0", name = "Default")) },
+ defaultSetId = cfg.defaultSetId,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/ScTuningStore.kt b/app/src/main/java/app/gamenative/steamcontroller/ScTuningStore.kt
new file mode 100644
index 0000000000..29908c3943
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/ScTuningStore.kt
@@ -0,0 +1,50 @@
+package app.gamenative.steamcontroller
+
+import android.content.Context
+
+/**
+ * Small global tuning store for Steam Controller feel values that a user adjusts by hand (hardware/grip
+ * dependent). Backed by SharedPreferences. Holds the two touchpad-feel knobs:
+ * - **Deadzone** — the resting-finger freeze radius (raw pad units) for relative-mouse pads. Within this radius
+ * of the anchor the cursor doesn't move at all (kills resting jitter); higher = stiller at rest.
+ * - **Smoothing** — a low-pass (0–100%) applied to motion that's left after the deadzone, and to the on-screen
+ * keyboard cursor. Higher = smoother but laggier; 0 = off.
+ * Read once when the live driver builds its interpreter (applies on next game launch).
+ */
+object ScTuningStore {
+ private const val PREFS = "sc_tuning"
+ private const val KEY_DEADZONE = "touchpad_deadzone"
+ private const val KEY_SMOOTHING = "touchpad_smoothing"
+
+ /** Deadzone default matches the built-in [ScProfile.PadMode.Mouse] jitterFloor. */
+ const val DEFAULT_DEADZONE = 24
+ const val MIN_DEADZONE = 0
+ const val MAX_DEADZONE = 100
+
+ // Measured BLE rest-jitter (right pad, finger still) is zero-mean noise spanning ~120–250 raw units with
+ // per-report deltas spiking to ~170 — far past the deadzone range, so a low-pass is the effective tool. 70 was
+ // dialed in on-device (Z Fold 7) as the lowest smoothing that kills rest-jitter without feeling floaty.
+ const val DEFAULT_SMOOTHING = 70
+ const val MIN_SMOOTHING = 0
+ const val MAX_SMOOTHING = 100
+
+ private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+
+ fun deadzone(context: Context): Int =
+ prefs(context).getInt(KEY_DEADZONE, DEFAULT_DEADZONE).coerceIn(MIN_DEADZONE, MAX_DEADZONE)
+
+ fun setDeadzone(context: Context, value: Int) {
+ prefs(context).edit().putInt(KEY_DEADZONE, value.coerceIn(MIN_DEADZONE, MAX_DEADZONE)).apply()
+ }
+
+ fun smoothing(context: Context): Int =
+ prefs(context).getInt(KEY_SMOOTHING, DEFAULT_SMOOTHING).coerceIn(MIN_SMOOTHING, MAX_SMOOTHING)
+
+ fun setSmoothing(context: Context, value: Int) {
+ prefs(context).edit().putInt(KEY_SMOOTHING, value.coerceIn(MIN_SMOOTHING, MAX_SMOOTHING)).apply()
+ }
+
+ /** Map a 0–100 smoothing percent to an EMA alpha (weight of the new sample): 0% → 1.0 (no smoothing),
+ * 100% → 0.15 (heavy). Shared by the pad-mouse and the keyboard cursor so one knob governs both. */
+ fun emaAlpha(smoothing: Int): Float = 1f - (smoothing.coerceIn(0, 100) / 100f) * 0.85f
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/ScUiBridge.kt b/app/src/main/java/app/gamenative/steamcontroller/ScUiBridge.kt
new file mode 100644
index 0000000000..b0dc42f8a7
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/ScUiBridge.kt
@@ -0,0 +1,46 @@
+package app.gamenative.steamcontroller
+
+/**
+ * The app-layer seam the [ProfileInterpreter] uses to drive GameNative's own UI (the QuickMenu + the in-game
+ * Steam-Controller editors) from the BLE controller. The BLE Triton is NOT an Android input device, so its input
+ * never reaches the Compose focus system on its own — this bridge lets the interpreter (a) open the QuickMenu and
+ * (b) translate controller movement/buttons into Android focus-nav key events while a menu/editor is up.
+ *
+ * Kept Android-free (no [android.view.KeyEvent]) so the engine stays unit-testable; [ScNavKey] is mapped to real
+ * Android keycodes in the XServerScreen implementation. Defaults to [NoOpScUiBridge] so headless tests and the
+ * no-overlay path run unchanged.
+ */
+interface ScUiBridge {
+ /** True while any controller-capturing GameNative overlay is up (QuickMenu OR an SC editor dialog / element
+ * editor / edit mode). While true the interpreter suppresses game output and routes input to [nav]. */
+ fun isMenuCapturing(): Boolean
+
+ /** Open the in-game QuickMenu (press edge of an [ScOutput.OpenQuickMenu] binding). Marshals to the UI thread. */
+ fun openQuickMenu()
+
+ /** Dispatch one focus-nav key to the Compose UI (marshals to the UI thread). */
+ fun nav(key: ScNavKey)
+
+ /** Move the on-screen pad-mouse cursor by a pixel delta (right trackpad while a menu/editor is captured). The
+ * bridge draws the cursor over the top dialog and clamps it to that window. Marshals to the UI thread. */
+ fun moveCursor(dx: Int, dy: Int) {}
+
+ /** Inject a tap (down+up) at the current cursor position into the top dialog (right-pad click). UI thread. */
+ fun cursorTap() {}
+
+ /** Remove the on-screen pad-mouse cursor (called when menu/editor capture ends so the dot doesn't stick). */
+ fun hideCursor() {}
+}
+
+/** Direction/selection intents emitted by the interpreter while a menu is captured; mapped to Android d-pad,
+ * DPAD_CENTER and back keycodes by the bridge implementation. [TAB_PREV]/[TAB_NEXT] (bumpers) flip between the
+ * command-picker tabs (Keyboard / Numpad / Mouse / Gamepad / …). [ZOOM_IN]/[ZOOM_OUT] (right/left trigger) resize
+ * the overlay in the placement editor; ignored elsewhere. */
+enum class ScNavKey { UP, DOWN, LEFT, RIGHT, SELECT, BACK, TAB_PREV, TAB_NEXT, HELP, CLOSE, ZOOM_IN, ZOOM_OUT }
+
+/** No-op bridge: no overlay attached / headless tests. */
+object NoOpScUiBridge : ScUiBridge {
+ override fun isMenuCapturing(): Boolean = false
+ override fun openQuickMenu() {}
+ override fun nav(key: ScNavKey) {}
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/TritonBle.kt b/app/src/main/java/app/gamenative/steamcontroller/TritonBle.kt
new file mode 100644
index 0000000000..71f42a93fc
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/TritonBle.kt
@@ -0,0 +1,371 @@
+package app.gamenative.steamcontroller
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import android.bluetooth.BluetoothManager
+import android.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanResult
+import android.bluetooth.le.ScanSettings
+import android.content.Context
+import android.os.Build
+import android.os.Handler
+import android.util.Log
+import java.util.ArrayDeque
+import java.util.UUID
+
+/**
+ * Direct Bluetooth-LE transport for the 2026 Steam Controller — the no-dongle, no-root path
+ * (docs/BLE-GATT-PATH.md). Finds the controller (bonded first, else scan by service UUID), connects GATT,
+ * **un-lizards it by writing settings to the control characteristic** (the BLE analog of USB's lizard-off —
+ * required because Android grabs the controller as a system HID, leaving it in lizard mode), subscribes to
+ * the input characteristic(s), and emits decoded [TritonState]s. A 2 s heartbeat re-sends lizard-off
+ * (firmware watchdog re-enables it). Requires runtime BLUETOOTH_CONNECT (+ SCAN if scanning).
+ *
+ * GATT ops are serialized through [opQueue] because Android allows only one outstanding op at a time.
+ */
+@SuppressLint("MissingPermission")
+class TritonBle(private val context: Context) {
+
+ companion object {
+ private const val TAG = "TritonBle"
+ val SERVICE_UUID: UUID = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3")
+ /** READ|WRITE control/report characteristic — feature reports (lizard, etc.) go here. */
+ val CONTROL_UUID: UUID = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3")
+ /** Triton input characteristics: 6c7a = report 0x45, 6c7c = report 0x47 (per SDL's Android bridge). */
+ val INPUT_TRITON_45: UUID = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3")
+ val INPUT_TRITON_47: UUID = UUID.fromString("100F6C7C-1735-4313-B402-38567131E5F3")
+ private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
+ private const val SCAN_TIMEOUT_MS = 6000L
+ // The 45-byte Triton report exceeds the default 23-byte BLE MTU, so it can't be delivered until we
+ // request a large MTU (Data Length Extensions). 517 is Android's "enable DLE" magic value (per SDL).
+ private const val TRITON_MTU = 517
+ // Resend lizard-off this often; the firmware watchdog re-enables it within ~3 s.
+ private const val LIZARD_HEARTBEAT_MS = 2000L
+ }
+
+ private val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
+ private val adapter = manager.adapter
+ private val handler = Handler(context.mainLooper)
+
+ private var gatt: BluetoothGatt? = null
+ private var control: BluetoothGattCharacteristic? = null
+ // Output-report characteristics keyed by report id (Triton: id = charByte - 0x35, for ids >= 0x80).
+ // Used to route USB-style output reports (haptics 0x82, etc.) to the right BLE char.
+ private val outputReportChars = HashMap()
+ @Volatile private var inputUuid: UUID? = null
+ private var scanner: android.bluetooth.le.BluetoothLeScanner? = null
+ private var scanCb: ScanCallback? = null
+ @Volatile private var closed = false
+
+ private val opQueue = ArrayDeque<() -> Unit>()
+ private var opBusy = false
+ private var opGen = 0
+ private var readyFired = false
+ private var rawNotifyCount = 0
+
+ private var onState: ((TritonState) -> Unit)? = null
+ private var onError: ((String) -> Unit)? = null
+ private var onReady: (() -> Unit)? = null
+
+ /** Optional raw-report sink (the exact bytes off the input characteristic) for golden-trace capture. */
+ @Volatile var onRaw: ((ByteArray) -> Unit)? = null
+
+ fun start(onState: (TritonState) -> Unit, onReady: () -> Unit, onError: (String) -> Unit) {
+ this.onState = onState
+ this.onReady = onReady
+ this.onError = onError
+
+ if (adapter == null) { fail("This device has no Bluetooth adapter."); return }
+ if (!adapter.isEnabled) { fail("Bluetooth is OFF — turn it on and retry."); return }
+
+ val bonded = runCatching {
+ adapter.bondedDevices?.firstOrNull { d ->
+ val n = d.name ?: ""
+ // Match the Steam Controller specifically — "Controller" alone also matches an Xbox pad.
+ n.contains("Steam", true) || n.contains("Valve", true)
+ }
+ }.getOrNull()
+ if (bonded != null) {
+ Log.i(TAG, "found bonded controller: ${bonded.name} ${bonded.address}")
+ connect(bonded)
+ } else {
+ Log.i(TAG, "no bonded controller; scanning for service $SERVICE_UUID")
+ scan()
+ }
+ }
+
+ private fun scan() {
+ val s = adapter.bluetoothLeScanner ?: run { fail("BLE scanner unavailable."); return }
+ scanner = s
+ // No service filter: some devices don't advertise the custom service UUID. Match by name OR
+ // advertised service in the callback (more robust for the unbonded/GATT-only connect).
+ val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
+ val cb = object : ScanCallback() {
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
+ val dev = result.device ?: return
+ val name = dev.name ?: result.scanRecord?.deviceName ?: ""
+ val advertisesService = result.scanRecord?.serviceUuids?.any { it.uuid == SERVICE_UUID } == true
+ val nameMatch = name.contains("Steam", true) || name.contains("Valve", true)
+ if (!advertisesService && !nameMatch) return
+ Log.i(TAG, "scan hit: '$name' ${dev.address} (service=$advertisesService)")
+ stopScan(); connect(dev)
+ }
+ override fun onScanFailed(errorCode: Int) { fail("BLE scan failed (code $errorCode).") }
+ }
+ scanCb = cb
+ s.startScan(null, settings, cb)
+ handler.postDelayed({
+ if (gatt == null && !closed) {
+ stopScan()
+ fail("No controller found over BLE in ${SCAN_TIMEOUT_MS / 1000}s. Is it in Bluetooth mode and connected?")
+ }
+ }, SCAN_TIMEOUT_MS)
+ }
+
+ private fun stopScan() {
+ runCatching { scanCb?.let { scanner?.stopScan(it) } }
+ scanCb = null
+ }
+
+ private fun connect(device: BluetoothDevice) {
+ gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
+ }
+
+ @Volatile private var discoverStarted = false
+ private fun discoverOnce(g: BluetoothGatt) {
+ if (discoverStarted) return
+ discoverStarted = true
+ g.discoverServices()
+ }
+
+ // ---- GATT operation queue (one outstanding op at a time) ----
+ private fun enqueue(op: () -> Unit) {
+ synchronized(opQueue) { opQueue.add(op) }
+ processNext()
+ }
+
+ private fun processNext() {
+ val op: (() -> Unit)?
+ val gen: Int
+ synchronized(opQueue) {
+ if (opBusy) return
+ op = opQueue.poll()
+ if (op == null) {
+ if (!readyFired) { readyFired = true; handler.post { onReady?.invoke(); startLizardHeartbeat() } }
+ return
+ }
+ opBusy = true
+ gen = ++opGen
+ }
+ // Watchdog: if a GATT op never calls back (e.g. a system-owned characteristic), force the queue on.
+ handler.postDelayed({
+ synchronized(opQueue) { if (!opBusy || opGen != gen) return@postDelayed }
+ Log.w(TAG, "op timed out — advancing queue")
+ opDone()
+ }, 1500)
+ op?.invoke()
+ }
+
+ private fun opDone() {
+ synchronized(opQueue) { opBusy = false }
+ processNext()
+ }
+
+ private fun enableNotify(ch: BluetoothGattCharacteristic) {
+ val g = gatt ?: return opDone()
+ g.setCharacteristicNotification(ch, true)
+ val cccd = ch.getDescriptor(CCCD_UUID) ?: run { Log.w(TAG, "no CCCD on ${ch.uuid}"); return opDone() }
+ @Suppress("DEPRECATION")
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ g.writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
+ } else {
+ cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
+ g.writeDescriptor(cccd)
+ }
+ }
+
+ private fun writeControl(bytes: ByteArray) {
+ val ch = control ?: return opDone()
+ writeTo(ch, bytes, noResponse = false)
+ }
+
+ private fun writeTo(ch: BluetoothGattCharacteristic, bytes: ByteArray, noResponse: Boolean) {
+ val g = gatt ?: return opDone()
+ val type = if (noResponse) BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
+ else BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
+ @Suppress("DEPRECATION")
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ g.writeCharacteristic(ch, bytes, type)
+ } else {
+ ch.value = bytes
+ ch.writeType = type
+ g.writeCharacteristic(ch)
+ }
+ }
+
+ /**
+ * Route a USB-style output report (`[id, payload...]`) to its BLE characteristic — the id selects the char
+ * (and is dropped over the air; the char encodes it), so only the payload is written. Used for haptics
+ * (report 0x82 → 6CB7). No-op until the output chars are discovered. Uses WRITE_NO_RESPONSE so frequent
+ * detent ticks stay cheap. Serialized through [opQueue] like every other GATT op.
+ */
+ fun writeOutputReport(report: ByteArray) {
+ if (closed || report.isEmpty()) return
+ val reportId = report[0].toInt() and 0xFF
+ val ch = outputReportChars[reportId] ?: return
+ val payload = report.copyOfRange(1, report.size)
+ enqueue { writeTo(ch, payload, noResponse = true) }
+ }
+
+ @Volatile private var heartbeatStarted = false
+ private fun startLizardHeartbeat() {
+ if (heartbeatStarted || control == null) return
+ heartbeatStarted = true
+ val r = object : Runnable {
+ override fun run() {
+ if (closed) return
+ enqueue { writeControl(TritonProtocol.bleLizardOff()) }
+ handler.postDelayed(this, LIZARD_HEARTBEAT_MS)
+ }
+ }
+ handler.postDelayed(r, LIZARD_HEARTBEAT_MS)
+ }
+
+ private val gattCallback = object : android.bluetooth.BluetoothGattCallback() {
+ override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
+ if (newState == BluetoothGatt.STATE_CONNECTED) {
+ Log.i(TAG, "connected (status=$status); requesting high priority + MTU $TRITON_MTU")
+ g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
+ // MUST raise the MTU before service discovery, or the 45-byte report can't be delivered.
+ if (!g.requestMtu(TRITON_MTU)) {
+ Log.w(TAG, "requestMtu returned false; discovering anyway")
+ discoverOnce(g)
+ }
+ // Safety: if onMtuChanged never fires, discover anyway after a short delay.
+ handler.postDelayed({ if (!closed) discoverOnce(g) }, 1500)
+ } else if (newState == BluetoothGatt.STATE_DISCONNECTED) {
+ if (!closed) fail("BLE disconnected (status=$status).")
+ }
+ }
+
+ override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
+ Log.i(TAG, "MTU negotiated = $mtu (status=$status)")
+ discoverOnce(g)
+ }
+
+ override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
+ for (service in g.services) {
+ Log.i(TAG, "service ${service.uuid}")
+ for (ch in service.characteristics) {
+ Log.i(TAG, " char ${ch.uuid} props=${propStr(ch.properties)}")
+ }
+ }
+ val svc = g.getService(SERVICE_UUID)
+ ?: run { fail("Connected, but the controller GATT service ($SERVICE_UUID) was not found."); return }
+ control = svc.getCharacteristic(CONTROL_UUID)
+
+ // Map output-report characteristics so haptics (report 0x82) etc. can be written over BLE: Triton
+ // exposes each output report id >= 0x80 as a dedicated char at (id + 0x35), e.g. 0x82 -> 6CB7.
+ outputReportChars.clear()
+ for (ch in svc.characteristics) {
+ val idByte = runCatching { ch.uuid.toString().substring(6, 8).toInt(16) }.getOrNull() ?: continue
+ val reportId = idByte - 0x35
+ if (reportId in 0x80..0xFF) outputReportChars[reportId] = ch
+ }
+ Log.i(TAG, "output report chars: ${outputReportChars.keys.map { "0x%02x".format(it) }}")
+
+ // Triton needs NO un-lizard to stream — just subscribe to the input characteristic (6c7a=report
+ // 0x45, else 6c7c=0x47). The full 45-byte report flows once the MTU is large enough (set above).
+ val input = svc.getCharacteristic(INPUT_TRITON_45) ?: svc.getCharacteristic(INPUT_TRITON_47)
+ if (input == null) {
+ fail("Triton input characteristic (6c7a/6c7c) not found on the controller service."); return
+ }
+ inputUuid = input.uuid
+ Log.i(TAG, "subscribing to Triton input ${input.uuid}")
+ enqueue { enableNotify(input) }
+ // Suppress lizard mode (mouse/keyboard emulation) so the cursor doesn't wander during gameplay.
+ // This does NOT stop the full report on 6c7a — only the HID emulation. Heartbeat keeps it off.
+ if (control != null) enqueue { writeControl(TritonProtocol.bleLizardOff()) }
+ }
+
+ override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
+ if (descriptor.uuid == CCCD_UUID) {
+ Log.i(TAG, "notify enabled on ${descriptor.characteristic.uuid} (status=$status)")
+ }
+ opDone()
+ }
+
+ override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
+ Log.i(TAG, "control write done on ${characteristic.uuid} status=$status")
+ opDone()
+ }
+
+ override fun onCharacteristicChanged(
+ g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray,
+ ) {
+ handleReport(characteristic, value)
+ }
+
+ @Deprecated("Deprecated in API 33")
+ @Suppress("DEPRECATION")
+ override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ characteristic.value?.let { handleReport(characteristic, it) }
+ }
+ }
+ }
+
+ private val lastPayload = HashMap()
+ private var changeLogCount = 0
+
+ private fun handleReport(ch: BluetoothGattCharacteristic, value: ByteArray) {
+ // Log on-CHANGE per characteristic so a button press reveals which channel carries the input
+ // (without flooding from steady-state streams). Key by uuid-tail + instanceId.
+ val key = "${ch.uuid.toString().substring(4, 8)}#${ch.instanceId}"
+ val hex = value.joinToString("") { "%02x".format(it) }
+ val prev = lastPayload.put(key, hex)
+ if (prev != hex && changeLogCount < 140) {
+ changeLogCount++
+ val shown = value.take(28).joinToString(" ") { "%02x".format(it) }
+ Log.i(TAG, "CHG $key len=${value.size} [$shown]")
+ }
+ rawNotifyCount++
+ onRaw?.invoke(value)
+ // The Triton input characteristic delivers the prefix-less 45-byte report (seq at offset 0).
+ val state = TritonProtocol.decodeBleState(value, value.size)
+ if (state != null) onState?.invoke(state)
+ }
+
+ private fun propStr(p: Int): String = buildList {
+ if (p and BluetoothGattCharacteristic.PROPERTY_READ != 0) add("READ")
+ if (p and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) add("WRITE")
+ if (p and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) add("WRITE_NR")
+ if (p and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) add("NOTIFY")
+ if (p and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) add("INDICATE")
+ }.joinToString("|").ifEmpty { "none" }
+
+ private fun fail(reason: String) {
+ if (closed) return
+ Log.w(TAG, "fail: $reason")
+ val cb = onError
+ onError = null
+ cb?.invoke(reason)
+ }
+
+ fun close() {
+ closed = true
+ stopScan()
+ handler.removeCallbacksAndMessages(null) // stop the lizard heartbeat + any pending discover
+ // Tear down the ACL link before releasing the client, or the controller stays connected at the OS level
+ // ("not released" after a game exits). disconnect() then close() is the documented clean teardown.
+ runCatching { gatt?.disconnect() }
+ runCatching { gatt?.close() }
+ gatt = null; control = null
+ outputReportChars.clear()
+ synchronized(opQueue) { opQueue.clear(); opBusy = false }
+ onState = null; onError = null; onReady = null
+ }
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/TritonHaptics.kt b/app/src/main/java/app/gamenative/steamcontroller/TritonHaptics.kt
new file mode 100644
index 0000000000..b9d802d0cd
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/TritonHaptics.kt
@@ -0,0 +1,94 @@
+package app.gamenative.steamcontroller
+
+import kotlin.math.abs
+
+/**
+ * Regenerates the trackpad "feel" Steam Input gives — required because disabling lizard mode (to read
+ * raw reports) turns off the firmware's automatic click-feel. Recipe + tuning from docs/HAPTICS-RESEARCH.md
+ * (captured from Steam Input via USBPcap and feel-confirmed on hardware):
+ * - per-pad CLICK = output report 0x82 02 (command 2 = CLICK)
+ * - slide DETENT = output report 0x82 01 (command 1 = TICK)
+ * - side ids: 0 = LEFT pad, 1 = RIGHT pad (2 is skipped; 3/4 = rumble motors)
+ * - the 0x81 pulse is NON-directional (fires both pads) -> not used for per-pad feedback
+ * Reports are written by the transport (over BLE via TritonBle.writeOutputReport) — this class just builds them.
+ */
+class TritonHaptics(private val writeOut: (ByteArray) -> Unit) {
+ companion object {
+ const val ID_OUT_HAPTIC_COMMAND = 0x82
+ const val ID_OUT_RUMBLE = 0x80
+ const val SIDE_LEFT_PAD = 0
+ const val SIDE_RIGHT_PAD = 1
+ const val CMD_TICK = 1
+ const val CMD_CLICK = 2
+
+ /**
+ * Build the Triton rumble output report (id `0x80`, 10 bytes, `#pragma pack(1)` little-endian) that
+ * drives the two rumble motors. Layout from SDL's `MsgHapticRumble` / `HIDAPI_DriverSteamTriton_Rumble`:
+ * `type`+`intensity` = 0, then per-motor `{ u16 speed; i8 gain }` for left (large / low-freq motor) and
+ * right (small / high-freq motor). We map XInput low/high-freq magnitudes straight to the motor speeds
+ * with 0 dB gain, exactly as SDL does.
+ */
+ fun rumbleReport(lowFreq: Int, highFreq: Int): ByteArray = ByteArray(10).also {
+ it[0] = ID_OUT_RUMBLE.toByte()
+ // [1] type = 0, [2..3] intensity = 0
+ it[4] = (lowFreq and 0xFF).toByte(); it[5] = ((lowFreq ushr 8) and 0xFF).toByte()
+ // [6] left gain = 0
+ it[7] = (highFreq and 0xFF).toByte(); it[8] = ((highFreq ushr 8) and 0xFF).toByte()
+ // [9] right gain = 0
+ }
+ }
+
+ private fun command(side: Int, cmd: Int, gain: Int): ByteArray =
+ byteArrayOf(ID_OUT_HAPTIC_COMMAND.toByte(), side.toByte(), cmd.toByte(), gain.toByte())
+
+ fun click(side: Int, gain: Int) = writeOut(command(side, CMD_CLICK, gain))
+ fun tick(side: Int, gain: Int) = writeOut(command(side, CMD_TICK, gain))
+
+ /** Drive the rumble motors from an in-game XInput rumble state (0..0xFFFF per motor). */
+ fun rumble(lowFreq: Int, highFreq: Int) = writeOut(rumbleReport(lowFreq, highFreq))
+
+ // per-pad slide accumulator: [touched, lastX, lastY, accum]
+ private val pads = arrayOf(PadState(), PadState())
+ private class PadState {
+ var touched = false; var lastX = 0; var lastY = 0; var accum = 0
+ }
+
+ /**
+ * Feed each decoded report here. Fires a click on a fresh pad-click and detent ticks as the thumb
+ * slides (jitter-filtered). [prevButtons] is the previous report's button mask for edge detection;
+ * [cfg] supplies the profile-driven feel parameters (gains, detent step, jitter floor, enables).
+ */
+ fun update(s: TritonState, prevButtons: Int, cfg: HapticSettings) {
+ if (!cfg.enabled) return
+ if (cfg.leftPadEnabled) {
+ handlePad(SIDE_LEFT_PAD, pads[0], s, prevButtons,
+ TritonProtocol.BTN_LPAD_TOUCH, TritonProtocol.BTN_LPAD_CLICK, s.leftPadX, s.leftPadY, cfg)
+ }
+ if (cfg.rightPadEnabled) {
+ handlePad(SIDE_RIGHT_PAD, pads[1], s, prevButtons,
+ TritonProtocol.BTN_RPAD_TOUCH, TritonProtocol.BTN_RPAD_CLICK, s.rightPadX, s.rightPadY, cfg)
+ }
+ }
+
+ private fun handlePad(
+ side: Int, st: PadState, s: TritonState, prev: Int,
+ touchBit: Int, clickBit: Int, x: Int, y: Int, cfg: HapticSettings
+ ) {
+ val rising = s.buttons and prev.inv()
+ if (rising and clickBit != 0) click(side, cfg.clickGain) // press-down click
+ if (s.has(touchBit)) {
+ if (!st.touched) {
+ st.touched = true; st.lastX = x; st.lastY = y; st.accum = 0
+ } else {
+ val d = abs(x - st.lastX) + abs(y - st.lastY)
+ st.lastX = x; st.lastY = y // track every report (reject slow drift)
+ if (d >= cfg.moveNoise) { // ignore stationary jitter
+ st.accum += d
+ if (st.accum >= cfg.detentStep) { tick(side, cfg.tickGain); st.accum -= cfg.detentStep }
+ }
+ }
+ } else {
+ st.touched = false
+ }
+ }
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/TritonMapper.kt b/app/src/main/java/app/gamenative/steamcontroller/TritonMapper.kt
new file mode 100644
index 0000000000..f7c58ca119
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/TritonMapper.kt
@@ -0,0 +1,180 @@
+package app.gamenative.steamcontroller
+
+import android.content.Context
+import android.os.Handler
+import android.util.Log
+import com.winlator.winhandler.WinHandler
+import com.winlator.xserver.XServer
+
+/**
+ * Drives a Steam Controller (2026 "Triton") into GameNative over **BLE** (no dongle, no root). [TritonBle]
+ * connects the GATT service, un-lizards, and pushes decoded [TritonState]s through a [ProfileInterpreter] (which
+ * applies the active [ScProfile] and feeds GameNative's injection seams: virtual XInput pad via WinHandler +
+ * mouse/keys via XServer), and regenerates the trackpad haptics (TritonHaptics) since lizard-off disables the
+ * firmware's.
+ *
+ * When a per-game [config] is supplied (from [ScConfigStore], keyed by container/game), the interpreter loads
+ * it and runs config-driven action-set switching / layers / mode-shift live in the game. With no config it
+ * falls back to the hardcoded [ScProfile.default]; swap [ProfileInterpreter.profile] to change bindings.
+ */
+class TritonMapper(
+ private val context: Context,
+ private val xServer: XServer,
+ private val config: ScConfig? = null,
+ /** Step-6 menu HUD; defaults to no-op so the driver runs without an attached overlay view. */
+ private val menuOverlay: ScMenuOverlay = NoOpScMenuOverlay,
+ /** Split-trackpad keyboard HUD; defaults to no-op. */
+ private val keyboardOverlay: ScKeyboardOverlay = NoOpScKeyboardOverlay,
+ /** [ScConfigStore] key (container/appId) this session was launched for. Enables live [reload] after an
+ * in-game edit re-resolves the config; null = use the passed [config] / built-in default with no reload. */
+ private val configKey: String? = null,
+ /** App-UI seam so the controller can open + navigate the QuickMenu / in-game editors. No-op by default. */
+ private val uiBridge: ScUiBridge = NoOpScUiBridge,
+) {
+ companion object {
+ private const val TAG = "TritonMapper"
+ private const val MAX_BLE_RETRIES = 4
+ private const val BLE_RETRY_MS = 1500L
+ // The controller stops rumbling ~50ms after the last report (firmware safety), so refresh a held
+ // rumble faster than that — matches SDL's TRITON_RUMBLE_RESEND_INTERVAL_MS.
+ private const val RUMBLE_RESEND_MS = 40L
+ }
+
+ // ble is read from the BLE binder thread (via the haptics writeOut lambda) but written on the main thread;
+ // interpreter/bleRetries are touched from both BLE callbacks and the main handler — mark volatile for visibility.
+ @Volatile private var ble: TritonBle? = null
+ private var haptics: TritonHaptics? = null
+ @Volatile private var interpreter: ProfileInterpreter? = null
+ @Volatile private var running = false
+
+ private val bleHandler = Handler(context.mainLooper)
+ @Volatile private var bleRetries = 0
+
+ // Game rumble forwarded from WinHandler's poller (another thread) → the controller's motors. Held on the
+ // main looper alongside every other BLE write. The resend loop refreshes a non-zero rumble before the
+ // firmware's ~50ms cutoff; it stops itself once rumble returns to zero.
+ @Volatile private var rumbleLow = 0
+ @Volatile private var rumbleHigh = 0
+ private val rumbleResend = object : Runnable {
+ override fun run() {
+ if (!running || (rumbleLow == 0 && rumbleHigh == 0)) return
+ haptics?.rumble(rumbleLow, rumbleHigh)
+ bleHandler.postDelayed(this, RUMBLE_RESEND_MS)
+ }
+ }
+
+ private fun onGameRumble(low: Int, high: Int) {
+ val wasActive = rumbleLow != 0 || rumbleHigh != 0
+ rumbleLow = low; rumbleHigh = high
+ haptics?.rumble(low, high)
+ if ((low != 0 || high != 0) && !wasActive) bleHandler.postDelayed(rumbleResend, RUMBLE_RESEND_MS)
+ }
+
+ /** True once the BLE transport is live (onReady). The in-game UI gates the rich Steam-Controller editing
+ * section on this — GameNative's generic controller detector can't see the BLE Triton. */
+ @Volatile var transportReady = false
+ private set
+
+ /** Start the BLE transport (the only transport). Feeds the [ProfileInterpreter] so action sets / layers /
+ * mode-shift / overlays / keyboard run in-game. */
+ fun start() {
+ startBle()
+ }
+
+ /**
+ * BLE transport: [TritonBle] connects/un-lizards/decodes and pushes [TritonState]s via [onState] (it runs
+ * its own lizard heartbeat, so no loop thread here). Haptics route back out over BLE via
+ * [TritonBle.writeOutputReport]. BLE direct-connects are flaky (a first attempt can time out with no GATT
+ * link), and the link can drop mid-game, so [onError] auto-retries with a fresh [TritonBle] — a successful
+ * [onReady] resets the budget.
+ */
+ private fun startBle() {
+ // Route haptics to whichever TritonBle is current (survives reconnects, which swap the [ble] instance).
+ val h = TritonHaptics { report -> ble?.writeOutputReport(report) }
+ haptics = h
+ val interp = buildInterpreter(h)
+ interpreter = interp
+ running = true
+ bleRetries = 0
+ // Forward game rumble (poller thread) onto the main looper so motor writes serialize with the rest.
+ WinHandler.scRumbleForwarder = WinHandler.RumbleForwarder { low, high ->
+ bleHandler.post { onGameRumble(low.toInt() and 0xFFFF, high.toInt() and 0xFFFF) }
+ }
+ connectBle(interp)
+ }
+
+ private fun connectBle(interp: ProfileInterpreter) {
+ val b = TritonBle(context)
+ ble = b
+ b.start(
+ onState = { state -> if (running) interp.apply(state) },
+ onReady = { bleRetries = 0; transportReady = true; Log.i(TAG, "started (BLE) — transport live") },
+ onError = { reason ->
+ Log.w(TAG, "BLE transport: $reason")
+ transportReady = false
+ runCatching { b.close() }
+ if (ble === b) ble = null
+ if (running && bleRetries < MAX_BLE_RETRIES) {
+ bleRetries++
+ Log.i(TAG, "BLE reconnect $bleRetries/$MAX_BLE_RETRIES in ${BLE_RETRY_MS}ms")
+ bleHandler.postDelayed({ if (running && ble == null) connectBle(interp) }, BLE_RETRY_MS)
+ } else if (running) {
+ Log.w(TAG, "BLE giving up after $MAX_BLE_RETRIES retries")
+ }
+ },
+ )
+ }
+
+ /** Build the interpreter for the active [config] (or [ScProfile.default]). */
+ private fun buildInterpreter(haptics: TritonHaptics?): ProfileInterpreter {
+ val cfg = config
+ return ProfileInterpreter(
+ XServerOutputSink(xServer),
+ cfg?.defaultProfile() ?: ScProfile.default(),
+ haptics,
+ menuOverlay = menuOverlay,
+ keyboardOverlay = keyboardOverlay,
+ padDeadzone = ScTuningStore.deadzone(context),
+ padSmoothing = ScTuningStore.smoothing(context),
+ uiBridge = uiBridge,
+ ).also {
+ if (cfg != null) {
+ it.setConfig(cfg)
+ Log.i(TAG, "loaded per-game ScConfig: sets=${cfg.sets.keys} default=${cfg.defaultSetId}")
+ } else {
+ Log.i(TAG, "no per-game config; using ScProfile.default()")
+ }
+ }
+ }
+
+ /**
+ * Re-resolve this session's config + tuning from the stores and push them into the running interpreter — so
+ * an in-game edit (bindings / labels / menu commit / deadzone / smoothing) applies LIVE with no relaunch.
+ * Safe to call from any thread (interpreter mutators are @Volatile / atomic field swaps).
+ */
+ fun reload() {
+ val interp = interpreter ?: return
+ val cfg = configKey?.let { ScConfigStore.forKey(context, it) }
+ if (cfg != null) {
+ interp.setConfig(cfg) // recompute() inside sets the active profile
+ } else {
+ interp.setConfig(null)
+ interp.profile = ScProfile.default()
+ }
+ interp.setPadTuning(ScTuningStore.deadzone(context), ScTuningStore.smoothing(context))
+ (menuOverlay as? ScMenuOverlayView)?.refreshLayouts() // pick up in-game per-menu overlay placement edits
+ Log.i(TAG, "reloaded (key=$configKey, cfg=${cfg != null}, sets=${cfg?.sets?.keys})")
+ }
+
+ fun stop() {
+ transportReady = false
+ running = false
+ if (WinHandler.scRumbleForwarder != null) WinHandler.scRumbleForwarder = null
+ rumbleLow = 0; rumbleHigh = 0
+ bleHandler.removeCallbacksAndMessages(null)
+ ble?.close(); ble = null
+ haptics = null
+ interpreter = null
+ Log.i(TAG, "stopped")
+ }
+}
diff --git a/app/src/main/java/app/gamenative/steamcontroller/TritonProtocol.kt b/app/src/main/java/app/gamenative/steamcontroller/TritonProtocol.kt
new file mode 100644
index 0000000000..8f25895200
--- /dev/null
+++ b/app/src/main/java/app/gamenative/steamcontroller/TritonProtocol.kt
@@ -0,0 +1,128 @@
+package app.gamenative.steamcontroller
+
+/**
+ * Steam Controller (2026 "Triton") USB protocol: device identity, report decode, and the feature-report
+ * payloads for init. Authored from this project's hardware-validated spec in docs/PUCK-PROTOCOL.md and
+ * docs/HAPTICS-RESEARCH.md (reference: Valve's protocol as exposed in SDL's hidapi steam_triton driver,
+ * Zlib). All multi-byte fields are little-endian; the wire report is [type, seq, buttons(u32), ...].
+ */
+object TritonProtocol {
+ // ---- input report types (ETritonReportIDTypes), at buf[0] ----
+ const val ID_STATE = 0x42
+ const val ID_STATE_BLE = 0x45 // this unit streams 0x45; same layout as 0x42
+ const val ID_STATE_TS = 0x47
+ const val ID_BATTERY = 0x43
+ const val ID_WIRELESS = 0x46
+ const val ID_WIRELESS_X = 0x79
+
+ // ---- feature-report (SET_REPORT) settings ----
+ const val ID_SET_SETTINGS_VALUES = 0x87
+ const val SETTING_LIZARD_MODE = 9
+ const val LIZARD_MODE_OFF = 0
+ const val SETTING_IMU_MODE = 48
+ const val IMU_RAW_ACCEL = 0x08
+ const val IMU_RAW_GYRO = 0x10
+
+ // ---- TritonButtons bitmask (buttons u32) ----
+ const val BTN_A = 0x00000001
+ const val BTN_B = 0x00000002
+ const val BTN_X = 0x00000004
+ const val BTN_Y = 0x00000008
+ const val BTN_QAM = 0x00000010 // Quick Access Menu (Steam "..." cluster)
+ const val BTN_R3 = 0x00000020
+ const val BTN_VIEW = 0x00000040
+ const val BTN_R4 = 0x00000080
+ const val BTN_R5 = 0x00000100
+ const val BTN_RBUMPER = 0x00000200
+ const val BTN_DPAD_DOWN = 0x00000400
+ const val BTN_DPAD_RIGHT = 0x00000800
+ const val BTN_DPAD_LEFT = 0x00001000
+ const val BTN_DPAD_UP = 0x00002000
+ const val BTN_MENU = 0x00004000
+ const val BTN_L3 = 0x00008000
+ const val BTN_STEAM = 0x00010000
+ const val BTN_L4 = 0x00020000
+ const val BTN_L5 = 0x00040000
+ const val BTN_LBUMPER = 0x00080000
+ const val BTN_RSTICK_TOUCH = 0x00100000
+ const val BTN_RPAD_TOUCH = 0x00200000
+ const val BTN_RPAD_CLICK = 0x00400000
+ const val BTN_RTRIG_CLICK = 0x00800000
+ const val BTN_LSTICK_TOUCH = 0x01000000
+ const val BTN_LPAD_TOUCH = 0x02000000
+ const val BTN_LPAD_CLICK = 0x04000000
+ const val BTN_LTRIG_CLICK = 0x08000000
+ const val BTN_RGRIP = 0x10000000
+ const val BTN_LGRIP = 0x20000000
+
+ private fun u16(b: ByteArray, o: Int) = (b[o].toInt() and 0xFF) or ((b[o + 1].toInt() and 0xFF) shl 8)
+ private fun s16(b: ByteArray, o: Int): Int {
+ val v = u16(b, o); return if (v >= 0x8000) v - 0x10000 else v
+ }
+ private fun u32(b: ByteArray, o: Int): Int =
+ (b[o].toInt() and 0xFF) or ((b[o + 1].toInt() and 0xFF) shl 8) or
+ ((b[o + 2].toInt() and 0xFF) shl 16) or ((b[o + 3].toInt() and 0xFF) shl 24)
+
+ /**
+ * Decode a BLE GATT input-characteristic value. The BLE transport delivers the SAME `TritonMTUNoQuat`
+ * payload but **without** the leading USB report-type byte, so seq is at offset 0 (buttons at offset 1).
+ * Confirmed vs CollinKite/SteamControllerKit (docs/BLE-GATT-PATH.md).
+ */
+ fun decodeBleState(buf: ByteArray, len: Int): TritonState? {
+ // Need indices 0..44 (gyroZ s16 at offset 43-44). BLE value arrays are exact-size, so guard >= 45.
+ if (len < 45) return null
+ return decodeFrom(buf, 0) // payload (seq) starts at offset 0
+ }
+
+ /** Shared field decode. [p] = index of the seq byte (USB=1 after the type byte; BLE=0). */
+ private fun decodeFrom(buf: ByteArray, p: Int): TritonState {
+ val s = TritonState()
+ s.buttons = u32(buf, p + 1)
+ s.triggerLeft = s16(buf, p + 5)
+ s.triggerRight = s16(buf, p + 7)
+ s.leftStickX = s16(buf, p + 9); s.leftStickY = s16(buf, p + 11)
+ s.rightStickX = s16(buf, p + 13); s.rightStickY = s16(buf, p + 15)
+ s.leftPadX = s16(buf, p + 17); s.leftPadY = s16(buf, p + 19)
+ s.rightPadX = s16(buf, p + 23); s.rightPadY = s16(buf, p + 25)
+ // imu: u32 ts at p+29, then s16 accelX/Y/Z, s16 gyroX/Y/Z
+ s.accelX = s16(buf, p + 33); s.accelY = s16(buf, p + 35); s.accelZ = s16(buf, p + 37)
+ s.gyroX = s16(buf, p + 39); s.gyroY = s16(buf, p + 41); s.gyroZ = s16(buf, p + 43)
+ return s
+ }
+
+ /**
+ * BLE settings write (to the control characteristic 100f6c34): same FeatureReportMsg as USB but WITHOUT
+ * the leading report-id byte (mirrors the input char, which drops the USB type prefix). Layout:
+ * [type=0x87, length=3, settingNum, valueLo, valueHi].
+ */
+ private fun bleSetting(settingNum: Int, value: Int): ByteArray = byteArrayOf(
+ ID_SET_SETTINGS_VALUES.toByte(), 3, settingNum.toByte(),
+ (value and 0xFF).toByte(), ((value shr 8) and 0xFF).toByte(),
+ )
+
+ fun bleLizardOff(): ByteArray = bleSetting(SETTING_LIZARD_MODE, LIZARD_MODE_OFF)
+ fun bleImuEnable(): ByteArray = bleSetting(SETTING_IMU_MODE, IMU_RAW_ACCEL or IMU_RAW_GYRO)
+}
+
+/** Decoded controller state. Sticks/pads/triggers are raw s16; gyro/accel raw s16 (scale in mapper). */
+class TritonState {
+ var buttons = 0
+ var triggerLeft = 0
+ var triggerRight = 0
+ var leftStickX = 0
+ var leftStickY = 0
+ var rightStickX = 0
+ var rightStickY = 0
+ var leftPadX = 0
+ var leftPadY = 0
+ var rightPadX = 0
+ var rightPadY = 0
+ var accelX = 0
+ var accelY = 0
+ var accelZ = 0
+ var gyroX = 0
+ var gyroY = 0
+ var gyroZ = 0
+
+ fun has(bit: Int) = (buttons and bit) != 0
+}
diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt
index 658a1fd9d3..756d054c2a 100644
--- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt
+++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt
@@ -1480,7 +1480,6 @@ fun PluviaMain(
appId = state.launchedAppId,
bootToContainer = state.bootToContainer,
testGraphics = state.testGraphics,
- diagnostics = state.diagnostics,
isOffline = xServerIsOffline,
registerBackAction = { cb ->
Timber.d("registerBackAction called: $cb")
diff --git a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt
index 6b634ec6f0..90f489334c 100644
--- a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt
+++ b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt
@@ -110,6 +110,11 @@ object QuickMenuAction {
const val TOUCHSCREEN_MODE = 7
const val DISABLE_MOUSE = 8
const val SHOOTER_MODE = 9
+ // Steam Controller (Triton) live editors — shown in the CONTROLLER tab only when an SC is connected.
+ const val SC_BINDINGS = 10
+ const val SC_LAYOUT = 12
+ // Single root entry that opens the Steam Controller editor hub (lists the editors above).
+ const val SC_ROOT = 13
}
private object QuickMenuTab {
@@ -255,6 +260,9 @@ fun QuickMenu(
onFpsLimiterEnabledChanged: (Boolean) -> Unit = {},
onFpsLimiterChanged: (Int) -> Unit = {},
hasPhysicalController: Boolean = false,
+ /** A Steam Controller (Triton) is live this session — surface its rich editors and hide the generic gamepad
+ * mapper (the BLE Triton isn't an Android input device, so the generic mapper doesn't apply to it). */
+ isSteamControllerLive: Boolean = false,
isTouchscreenModeActive: Boolean = false,
onTouchGestureSettingsClick: () -> Unit = {},
isShooterModeActive: Boolean = false,
@@ -279,6 +287,10 @@ fun QuickMenu(
)
val controllerItems = buildList {
+ // Steam Controller editors live behind a single root entry (most relevant when a Triton is connected).
+ if (isSteamControllerLive) {
+ add(QuickMenuItem(QuickMenuAction.SC_ROOT, Icons.Filled.Gamepad, R.string.sc_edit_root, PluviaTheme.colors.accentPurple))
+ }
add(
QuickMenuItem(
id = QuickMenuAction.DISABLE_MOUSE,
@@ -303,7 +315,8 @@ fun QuickMenu(
accentColor = PluviaTheme.colors.accentPurple,
)
)
- if (hasPhysicalController) {
+ // The generic gamepad mapper doesn't apply to the BLE Steam Controller — hide it when an SC is live.
+ if (hasPhysicalController && !isSteamControllerLive) {
add(
QuickMenuItem(
id = QuickMenuAction.EDIT_PHYSICAL_CONTROLLER,
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt
index 845c7a2356..32eb68f66d 100644
--- a/app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt
@@ -16,6 +16,9 @@ import com.alorma.compose.settings.ui.SettingsGroup
import com.alorma.compose.settings.ui.SettingsSwitch
import com.winlator.container.Container
+// Steam Controller (Triton) config is intentionally NOT here — it lives entirely in the in-game QuickMenu so it
+// never bleeds into the generic container/controller settings (it matters to almost no one there). See
+// XServerScreen's ScLiveEditorDialogs (Manage configs / Bindings / Labels / Tuning / Overlay / Keyboard).
@Composable
fun ControllerTabContent(state: ContainerConfigState, default: Boolean) {
val config = state.config.value
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ScConfigManagerDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ScConfigManagerDialog.kt
new file mode 100644
index 0000000000..0d3c409e75
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ScConfigManagerDialog.kt
@@ -0,0 +1,168 @@
+package app.gamenative.ui.component.dialog
+
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import app.gamenative.steamcontroller.ScConfigKind
+import app.gamenative.steamcontroller.ScConfigStore
+import app.gamenative.ui.util.SnackbarManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * In-game Steam Controller config management — the piece that used to live in the container-config Controller tab,
+ * now folded into the QuickMenu so SC settings never bleed into container settings (they matter to almost no one
+ * there). Lists the running game's saved configs ([storeKey]) and lets the user pick the active one, duplicate /
+ * rename / delete it, import a Steam `.vdf`, and clear custom menu labels. [onChanged] fires after any change so the
+ * live driver reloads with no relaunch. Controller-navigable (d-pad + A, B = back) like the other QuickMenu editors;
+ * the pad cursor also works. Editing the active config's bindings lives in the separate Bindings editor.
+ */
+@Composable
+fun ScConfigManagerDialog(storeKey: String, onChanged: () -> Unit, onDismiss: () -> Unit) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ // Bumped after any change to recompute the list + active entry.
+ var version by remember { mutableIntStateOf(0) }
+ val configs = remember(version) { ScConfigStore.listConfigs(context, storeKey) }
+ val activeId = remember(version) { ScConfigStore.activeConfigId(context, storeKey) }
+ val activeEntry = configs.firstOrNull { it.id == activeId } ?: configs.firstOrNull()
+ val hasLabels = remember(version) { ScConfigStore.hasLabels(context, storeKey) }
+
+ var showSelector by remember { mutableStateOf(false) }
+ var nameAction by remember { mutableStateOf(null) }
+
+ val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
+ uri ?: return@rememberLauncherForActivityResult
+ scope.launch {
+ val text = withContext(Dispatchers.IO) {
+ runCatching { context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } }.getOrNull()
+ }
+ if (text.isNullOrBlank()) { SnackbarManager.show("Could not read the selected file"); return@launch }
+ val parsed = withContext(Dispatchers.IO) { ScConfigStore.validate(text) }
+ if (parsed == null) { SnackbarManager.show("Not a valid Steam Controller .vdf config"); return@launch }
+ // Name the config from the vdf's top-level `title` (e.g. "testGyroect123"), falling back to the file name,
+ // then a generic label — instead of the old "Imported (.vdf)" (which doubled the "(.vdf)" list suffix).
+ val title = Regex("\"title\"\\s+\"([^\"#][^\"]*)\"").find(text)?.groupValues?.getOrNull(1)?.trim()
+ val fileName = runCatching {
+ context.contentResolver.query(uri, null, null, null, null)?.use { c ->
+ val i = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
+ if (i >= 0 && c.moveToFirst()) c.getString(i)?.substringBeforeLast('.') else null
+ }
+ }.getOrNull()
+ val name = title?.takeIf { it.isNotBlank() } ?: fileName?.takeIf { it.isNotBlank() } ?: "Imported"
+ val newId = withContext(Dispatchers.IO) { ScConfigStore.importVdfConfig(context, storeKey, text, name) }
+ version++; onChanged()
+ SnackbarManager.show(
+ if (newId != null) "Imported ${parsed.sets.size} action set(s) — now active" else "Could not save the imported config",
+ )
+ }
+ }
+
+ Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(0.95f).padding(8.dp),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ val nav = remember { ScNavState() }
+ ScNavDialogColumn(nav, onBack = onDismiss, modifier = Modifier.padding(16.dp)) {
+ Text("Manage configs", style = MaterialTheme.typography.titleLarge)
+ Text(
+ if (activeEntry == null) "No saved config — using the built-in default mapping."
+ else "${configs.size} saved · active: ${activeEntry.name}",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
+ )
+ HorizontalDivider()
+
+ if (activeEntry != null) {
+ ScNavRow(nav, 0, "Active config: ${activeEntry.name} ▾") { showSelector = true }
+ ScNavRow(nav, 1, "Duplicate active config") {
+ nameAction = NameAction(NameActionKind.DUPLICATE, activeEntry.id, "${activeEntry.name} copy")
+ }
+ ScNavRow(nav, 2, "Rename active config") {
+ nameAction = NameAction(NameActionKind.RENAME, activeEntry.id, activeEntry.name)
+ }
+ ScNavRow(nav, 3, "Delete active config") {
+ if (ScConfigStore.deleteConfig(context, storeKey, activeEntry.id)) {
+ version++; onChanged(); SnackbarManager.show("Deleted ${activeEntry.name}")
+ }
+ }
+ }
+ ScNavRow(nav, 4, "Import config (.vdf)") { importLauncher.launch(arrayOf("*/*")) }
+ if (hasLabels) {
+ ScNavRow(nav, 5, "Remove custom menu labels") {
+ if (ScConfigStore.removeLabels(context, storeKey)) {
+ version++; onChanged(); SnackbarManager.show("Removed custom menu labels")
+ }
+ }
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
+ Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
+ ScNavItem(nav, line = 6, onActivate = onDismiss) { ScActionChip("Done", onClick = onDismiss, filled = true) }
+ }
+ }
+ }
+ }
+
+ if (showSelector) {
+ ScNavChoiceDialog(
+ title = "Active config",
+ options = configs.map { it.id to (it.name + if (it.kind == ScConfigKind.VDF) " (.vdf)" else " (custom)") },
+ selected = activeId ?: "",
+ onPick = { id -> if (ScConfigStore.setActiveConfig(context, storeKey, id)) { version++; onChanged() } },
+ onDismiss = { showSelector = false },
+ )
+ }
+
+ nameAction?.let { action ->
+ ScOnScreenKeyboardDialog(
+ label = if (action.kind == NameActionKind.DUPLICATE) "Duplicate as" else "Rename to",
+ initial = action.initial,
+ onCancel = { nameAction = null },
+ onDone = { name ->
+ val ok = when (action.kind) {
+ NameActionKind.DUPLICATE -> ScConfigStore.duplicateConfig(context, storeKey, action.configId, name) != null
+ NameActionKind.RENAME -> ScConfigStore.renameConfig(context, storeKey, action.configId, name)
+ }
+ nameAction = null
+ if (ok) { version++; onChanged(); SnackbarManager.show(if (action.kind == NameActionKind.DUPLICATE) "Duplicated" else "Renamed") }
+ },
+ )
+ }
+}
+
+/** A full-width, d-pad-navigable text row (the config manager's list items all share this shape). */
+@Composable
+private fun ScNavRow(nav: ScNavState, line: Int, label: String, onActivate: () -> Unit) {
+ ScNavItem(nav, line = line, modifier = Modifier.fillMaxWidth(), onActivate = onActivate) {
+ Text(label, modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 8.dp))
+ }
+}
+
+private enum class NameActionKind { DUPLICATE, RENAME }
+
+/** A pending duplicate/rename name prompt for config [configId]. */
+private data class NameAction(val kind: NameActionKind, val configId: String, val initial: String)
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ScCursorController.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ScCursorController.kt
new file mode 100644
index 0000000000..3ed99ba3e6
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ScCursorController.kt
@@ -0,0 +1,110 @@
+package app.gamenative.ui.component.dialog
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Path
+import android.os.SystemClock
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+
+/**
+ * Draws a pad-mouse cursor over the currently-open Steam-Controller dialog and injects taps at the cursor location,
+ * so the right trackpad can drive arbitrary Compose UI (lists, chips, dropdowns, text fields) that d-pad focus
+ * traversal can't reliably reach. The BLE Triton isn't an Android input device, so the [ProfileInterpreter] emits
+ * pixel deltas / clicks through [app.gamenative.steamcontroller.ScUiBridge] and the bridge calls into this.
+ *
+ * The cursor is a small View added to the **top dialog window's decor** (so it floats above the Compose content);
+ * coordinates are tracked in that decor's space and taps are dispatched to the decor, which hit-tests down to the
+ * Compose view beneath the (non-clickable) cursor. When the top dialog changes the cursor re-attaches and recenters.
+ */
+class ScCursorController(private val context: Context) {
+ private var cursorView: View? = null
+ private var host: ViewGroup? = null
+ private var x = 0f
+ private var y = 0f
+
+ /** Move the cursor by a pixel delta over [topView]'s window, attaching/recentering if the top window changed. */
+ fun move(topView: View?, dx: Int, dy: Int) {
+ val decor = topView?.rootView as? ViewGroup ?: run { detach(); return }
+ if (host !== decor || cursorView == null) attach(decor)
+ val cv = cursorView ?: return
+ val w = decor.width.coerceAtLeast(1)
+ val h = decor.height.coerceAtLeast(1)
+ x = (x + dx).coerceIn(0f, (w - 1).toFloat())
+ y = (y + dy).coerceIn(0f, (h - 1).toFloat())
+ // The arrow's tip is at the view's top-left, so align the tip (the click hotspot) to the tracked position.
+ cv.translationX = x
+ cv.translationY = y
+ cv.visibility = View.VISIBLE
+ cv.bringToFront()
+ }
+
+ /** Inject a tap (down+up) at the current cursor position into the top dialog. */
+ fun tap(topView: View?) {
+ val decor = topView?.rootView as? ViewGroup ?: return
+ val now = SystemClock.uptimeMillis()
+ val down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0)
+ val up = MotionEvent.obtain(now, now + 12, MotionEvent.ACTION_UP, x, y, 0)
+ decor.dispatchTouchEvent(down)
+ decor.dispatchTouchEvent(up)
+ down.recycle()
+ up.recycle()
+ }
+
+ private fun attach(decor: ViewGroup) {
+ detach()
+ val arrow = PointerCursorView(context)
+ decor.addView(arrow, FrameLayout.LayoutParams(arrow.viewW, arrow.viewH))
+ cursorView = arrow
+ host = decor
+ // Start centered in the window.
+ x = decor.width / 2f
+ y = decor.height / 2f
+ }
+
+ /**
+ * A classic arrow-pointer glyph (white fill, dark outline) drawn with a [Path] so it's crisp at any density and
+ * needs no drawable resource. The tip sits at the view's top-left (0,0) — that's the click hotspot the parent
+ * aligns to the tracked position. Replaces the old white-dot cursor.
+ */
+ private class PointerCursorView(context: Context) : View(context) {
+ private val d = context.resources.displayMetrics.density
+ private val s = d * 1.5f // glyph scale (arrow "units" → px)
+ // Arrow outline in units (tip at 0,0), classic pointer shape.
+ private val pts = floatArrayOf(0f, 0f, 0f, 12f, 2.8f, 9.4f, 4.7f, 13.9f, 6.3f, 13.1f, 4.4f, 8.7f, 7.5f, 8.7f)
+ val viewW = Math.ceil((7.5f * s + 2 * d).toDouble()).toInt()
+ val viewH = Math.ceil((13.9f * s + 2 * d).toDouble()).toInt()
+
+ private val path = Path().apply {
+ moveTo(pts[0] * s, pts[1] * s)
+ var i = 2
+ while (i < pts.size) { lineTo(pts[i] * s, pts[i + 1] * s); i += 2 }
+ close()
+ }
+ private val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL; color = Color.WHITE }
+ private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE; strokeWidth = 1.5f * d; color = Color.argb(235, 20, 20, 20)
+ strokeJoin = Paint.Join.ROUND
+ }
+
+ init { isClickable = false; isFocusable = false }
+
+ override fun onDraw(canvas: Canvas) {
+ canvas.drawPath(path, fill)
+ canvas.drawPath(path, stroke)
+ }
+ }
+
+ /** Remove the cursor (top window closed / capture ended). Safe to call repeatedly. */
+ fun detach() {
+ val cv = cursorView
+ val h = host
+ if (cv != null && h != null) h.removeView(cv)
+ cursorView = null
+ host = null
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ScEditorStyle.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ScEditorStyle.kt
new file mode 100644
index 0000000000..e64fa0cf3b
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ScEditorStyle.kt
@@ -0,0 +1,71 @@
+package app.gamenative.ui.component.dialog
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+
+/**
+ * Shared visual language for the Steam-Controller editors, so every SC menu / sub-menu looks the same and matches
+ * GameNative's app style. The selection ring (rotating gradient), Xbox button glyphs, scrollbar, and rounded text
+ * field live in their own files ([ScSelectionRing]/[ScNavItem], [ScButtonGlyph], [ScScrollbar], [ScTextEditField]);
+ * this file holds the chip + section-header primitives.
+ */
+
+/** Section header matching GameNative's OptionSectionHeader: uppercased, primary @ 0.8, labelMedium, 1.5× spacing. */
+@Composable
+fun ScSectionHeader(text: String) {
+ Text(
+ text.uppercase(),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
+ letterSpacing = MaterialTheme.typography.labelMedium.letterSpacing * 1.5f,
+ modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
+ )
+}
+
+/**
+ * The oval chip used everywhere in the SC editors (matches GameNative's FlowFilterChip): filled-primary when
+ * [selected], 2dp-primary outline when not, 16dp radius. No outer padding — the nav-selection ring is drawn at this
+ * element's bounds, so padding would leave a gap between the ring and the pill; space chips via the parent's
+ * arrangement. When !enabled it dims and stops responding to taps.
+ */
+@Composable
+fun ScChip(label: String, selected: Boolean, onClick: () -> Unit, enabled: Boolean = true) {
+ val shape = RoundedCornerShape(16.dp)
+ // Colour language: SOLID purple + white text = the active / currently-bound choice; hollow WHITE outline = an
+ // available option. (The rotating gradient ring, drawn separately, is the nav cursor — a third, distinct state.)
+ val active = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
+ val option = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
+ Box(
+ modifier = Modifier
+ .clip(shape)
+ .then(
+ if (selected) Modifier.background(active, shape)
+ else Modifier.border(2.dp, option, shape),
+ )
+ .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier)
+ .padding(horizontal = 16.dp, vertical = 9.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ label,
+ style = MaterialTheme.typography.labelLarge,
+ color = if (selected) MaterialTheme.colorScheme.onPrimary else option,
+ )
+ }
+}
+
+/** An action button styled as an [ScChip] — [filled] (primary) for the primary action (Save/Done), outline otherwise. */
+@Composable
+fun ScActionChip(label: String, onClick: () -> Unit, filled: Boolean = false) =
+ ScChip(label = label, selected = filled, onClick = onClick)
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ScInputGlyph.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ScInputGlyph.kt
new file mode 100644
index 0000000000..68f1a825b4
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ScInputGlyph.kt
@@ -0,0 +1,72 @@
+package app.gamenative.ui.component.dialog
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import app.gamenative.PrefManager
+import app.gamenative.steamcontroller.ScMenuNav
+import app.gamenative.steamcontroller.TritonProtocol
+import app.gamenative.ui.icons.InputIcons
+
+/**
+ * Renders a controller button as GameNative's own Xbox glyph (Kenney input-prompt icons via [InputIcons]) instead of
+ * a text badge, so the SC editors' button hints match the rest of the app's prompts (e.g. the library action bar).
+ * Honors [PrefManager.swapFaceButtons] like GameNative's `GamepadButtonHint` does — the A/B/X/Y *icon* swaps to match
+ * the user's controller labelling (the physical mapping is unchanged).
+ */
+private fun xboxGlyphRes(buttonBit: Int, swapFaceButtons: Boolean): Int? = when (buttonBit) {
+ TritonProtocol.BTN_A -> if (swapFaceButtons) InputIcons.Xbox.buttonColorB else InputIcons.Xbox.buttonColorA
+ TritonProtocol.BTN_B -> if (swapFaceButtons) InputIcons.Xbox.buttonColorA else InputIcons.Xbox.buttonColorB
+ TritonProtocol.BTN_X -> if (swapFaceButtons) InputIcons.Xbox.buttonColorY else InputIcons.Xbox.buttonColorX
+ TritonProtocol.BTN_Y -> if (swapFaceButtons) InputIcons.Xbox.buttonColorX else InputIcons.Xbox.buttonColorY
+ TritonProtocol.BTN_LBUMPER -> InputIcons.Xbox.lb
+ TritonProtocol.BTN_RBUMPER -> InputIcons.Xbox.rb
+ TritonProtocol.BTN_MENU -> InputIcons.Xbox.menu
+ TritonProtocol.BTN_VIEW -> InputIcons.Xbox.view
+ TritonProtocol.BTN_STEAM -> InputIcons.Xbox.guide // no "Steam" glyph; the Guide button is the closest match
+ else -> null
+}
+
+/** A single Xbox button glyph for the given SC [buttonBit], or nothing if that bit has no glyph. */
+@Composable
+fun ScButtonGlyph(buttonBit: Int, modifier: Modifier = Modifier, size: Dp = 24.dp) {
+ val res = xboxGlyphRes(buttonBit, PrefManager.swapFaceButtons) ?: return
+ Image(painter = painterResource(res), contentDescription = null, modifier = modifier.size(size))
+}
+
+/** The d-pad glyph (directional focus movement). */
+@Composable
+fun ScDpadGlyph(modifier: Modifier = Modifier, size: Dp = 24.dp) {
+ Image(painter = painterResource(InputIcons.Xbox.dpad), contentDescription = null, modifier = modifier.size(size))
+}
+
+/** One "glyph — description" row for the menu-nav help list, rendered from a fixed [ScMenuNav.Control]. */
+@Composable
+fun ScNavHelpRow(control: ScMenuNav.Control) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ ScButtonGlyph(control.buttonBit, size = 26.dp)
+ Text(control.desc, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
+ }
+}
+
+/** The directions ("d-pad / left stick → move") help row. */
+@Composable
+fun ScNavHelpDirectionsRow() {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ ScDpadGlyph(size = 26.dp)
+ Text(
+ "${ScMenuNav.DIRECTIONS_HINT} — ${ScMenuNav.DIRECTIONS_DESC}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ScListNav.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ScListNav.kt
new file mode 100644
index 0000000000..ad32172a59
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ScListNav.kt
@@ -0,0 +1,218 @@
+package app.gamenative.ui.component.dialog
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+
+/**
+ * Explicit item-by-item d-pad selection for a Steam-Controller editor dialog. Compose's own focus traversal
+ * (`moveFocus`) proved unreliable inside these Dialog + FlowRow + verticalScroll layouts (the key was consumed but
+ * focus never moved — confirmed on-device), so navigable controls are modelled as a grid of (line, col) cells: d-pad
+ * up/down changes [line], left/right changes [col] within that line, and A activates the selected cell. Works for ANY
+ * controller (it only needs d-pad + A), independent of the pad cursor which still points/clicks freely.
+ *
+ * Each navigable control wraps its content in [ScNavItem], which registers the cell + its activation, draws the
+ * selection highlight, and scrolls itself into view when selected. Lines are numbered by the caller in visual order.
+ */
+class ScNavState {
+ var line by mutableIntStateOf(0)
+ private set
+ var col by mutableIntStateOf(0)
+ private set
+
+ // Bounds are derived dynamically from the registered cells (not cached), so navigation only ever visits cells
+ // that currently exist — this makes it robust to content that changes under it (e.g. switching command-picker
+ // tabs disposes one set of cells and registers another).
+ private val activators = mutableStateMapOf Unit>()
+ // Cells that consume left/right themselves (e.g. a slider: d-pad L/R adjusts its value) instead of moving columns.
+ private val horizontals = mutableStateMapOf Unit>()
+
+ private fun keyOf(l: Int, c: Int) = (l.toLong() shl 32) or (c.toLong() and 0xffffffffL)
+ private fun lineOf(k: Long) = (k ushr 32).toInt()
+ private fun colOf(k: Long) = (k and 0xffffffffL).toInt()
+ private fun lines() = activators.keys.map(::lineOf).distinct().sorted()
+ private fun colsOn(l: Int) = activators.keys.filter { lineOf(it) == l }.map(::colOf).distinct().sorted()
+
+ fun register(l: Int, c: Int, onHorizontal: ((Int) -> Unit)? = null, onActivate: () -> Unit) {
+ activators[keyOf(l, c)] = onActivate
+ if (onHorizontal != null) horizontals[keyOf(l, c)] = onHorizontal else horizontals.remove(keyOf(l, c))
+ }
+ fun unregister(l: Int, c: Int) { activators.remove(keyOf(l, c)); horizontals.remove(keyOf(l, c)) }
+
+ fun moveVertical(d: Int) {
+ val ls = lines()
+ if (ls.isEmpty()) return
+ val idx = ls.indexOf(line).let { if (it >= 0) it else ls.indexOfFirst { l -> l >= line }.let { j -> if (j < 0) ls.lastIndex else j } }
+ line = ls[(idx + d).coerceIn(0, ls.lastIndex)]
+ val cs = colsOn(line)
+ if (cs.isNotEmpty() && col !in cs) col = cs.minByOrNull { kotlin.math.abs(it - col) } ?: cs.first()
+ }
+
+ fun moveHorizontal(d: Int) {
+ // If the selected cell adjusts itself with L/R (a slider), let it consume the input instead of moving columns.
+ horizontals[keyOf(line, col)]?.let { it(d); return }
+ val cs = colsOn(line)
+ if (cs.isEmpty()) return
+ val idx = cs.indexOf(col).let { if (it < 0) 0 else it }
+ col = cs[(idx + d).coerceIn(0, cs.lastIndex)]
+ }
+
+ /** Snap selection to the first existing cell (call when the navigable content is replaced, e.g. on a tab change). */
+ fun reset() {
+ val ls = lines()
+ line = ls.firstOrNull() ?: 0
+ col = colsOn(line).firstOrNull() ?: 0
+ }
+
+ fun activate() { activators[keyOf(line, col)]?.invoke() }
+
+ fun isSelected(l: Int, c: Int) = line == l && col == c
+}
+
+/**
+ * Wraps a navigable editor control: registers its (line,col) cell + activation with [state], highlights it when
+ * selected, and scrolls it into view (within an enclosing verticalScroll) when selected. Pass [modifier] =
+ * `Modifier.fillMaxWidth()` for full-width rows; leave default for inline chips so they keep their flow width.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ScNavItem(
+ state: ScNavState,
+ line: Int,
+ col: Int = 0,
+ modifier: Modifier = Modifier,
+ /** Optional L/R handler so this cell adjusts itself with d-pad left/right (a slider) instead of changing columns. */
+ onHorizontal: ((Int) -> Unit)? = null,
+ onActivate: () -> Unit,
+ content: @Composable () -> Unit,
+) {
+ val bring = remember { BringIntoViewRequester() }
+ // Register STABLE wrappers that always call the latest lambdas: the DisposableEffect only re-runs when (line,col)
+ // change, so a lambda capturing mutable state (e.g. the selected action-set index) would otherwise be frozen at
+ // first registration — which made "Delete" always remove the initially-selected set.
+ val latestActivate = rememberUpdatedState(onActivate)
+ val latestHorizontal = rememberUpdatedState(onHorizontal)
+ DisposableEffect(line, col) {
+ state.register(line, col, onHorizontal = if (latestHorizontal.value != null) { d -> latestHorizontal.value?.invoke(d) } else null) { latestActivate.value() }
+ onDispose { state.unregister(line, col) }
+ }
+ val selected = state.isSelected(line, col)
+ LaunchedEffect(selected) { if (selected) runCatching { bring.bringIntoView() } }
+ // Radius matches the chip buttons (16dp) so the highlight hugs their corners instead of leaving gaps.
+ val shape = RoundedCornerShape(16.dp)
+ Box(
+ modifier = modifier
+ .bringIntoViewRequester(bring)
+ // A subtle fill keeps the selection readable on plain rows (the QuickMenu items sit on their own cards).
+ .then(if (selected) Modifier.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), shape) else Modifier)
+ // The QuickMenu's rotating gradient ring, driven synchronously by [selected] (see ScSelectionRing).
+ .scSelectionRing(selected, shape, width = 2.dp),
+ ) {
+ // QuickMenu pattern: white content normally, primary (purple) when selected. Rows/labels that don't set an
+ // explicit color inherit this; chips/summaries that set their own color are unaffected.
+ val contentColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
+ CompositionLocalProvider(LocalContentColor provides contentColor) { content() }
+ }
+}
+
+/**
+ * The standard controller-navigable container for an SC editor dialog's content: a focusable [Column] that drives
+ * [state] (d-pad up/down = lines, left/right = columns or a slider's value, A = activate) and registers on the
+ * [ScNavDialogStack] so the SC bridge routes keys here and B runs [onBack]. Optional [onBumper] handles LB/RB. Wrap
+ * the dialog's content in this and place [ScNavItem]s inside.
+ */
+/**
+ * Captures + holds keyboard focus for a controller-nav dialog root. A freshly-opened Compose Dialog window isn't
+ * focused on frame 1 (so `onPreviewKeyEvent` wouldn't fire), so this hammers `requestFocus` until it lands, then keeps
+ * the node focusable. Bundles the `focusRequester + onFocusChanged + focusable` + retry loop every SC dialog root
+ * repeated; pair it with your own `.onPreviewKeyEvent { … }`.
+ */
+@Composable
+fun Modifier.scCaptureFocus(): Modifier {
+ val focus = remember { FocusRequester() }
+ var hasFocus by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ repeat(80) { if (hasFocus) return@LaunchedEffect; runCatching { focus.requestFocus() }; delay(25) }
+ }
+ return this.focusRequester(focus).onFocusChanged { hasFocus = it.hasFocus }.focusable()
+}
+
+@Composable
+fun ScNavDialogColumn(
+ state: ScNavState,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ onBumper: ((Int) -> Unit)? = null,
+ /** Optional Y-button handler (e.g. open a Help dialog); null = ignored. */
+ onHelp: (() -> Unit)? = null,
+ /** Optional Start-button handler (e.g. close the editor back to the game); null = ignored. */
+ onClose: (() -> Unit)? = null,
+ /** When true, the content scrolls inside the [ScScrollbar] (the same auto-hiding accent bar the main bindings list
+ * uses) instead of the caller adding its own scroll modifier — keeps every SC dialog's scrollbar identical. */
+ scrollable: Boolean = false,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val keyHandling = Modifier
+ .scCaptureFocus()
+ .onPreviewKeyEvent { ev ->
+ if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+ when (ev.key) {
+ Key.DirectionDown -> { state.moveVertical(1); true }
+ Key.DirectionUp -> { state.moveVertical(-1); true }
+ Key.DirectionRight -> { state.moveHorizontal(1); true }
+ Key.DirectionLeft -> { state.moveHorizontal(-1); true }
+ Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.ButtonA -> { state.activate(); true }
+ Key.ButtonL1 -> if (onBumper != null) { onBumper(-1); true } else false
+ Key.ButtonR1 -> if (onBumper != null) { onBumper(1); true } else false
+ Key.ButtonY -> if (onHelp != null) { onHelp(); true } else false
+ Key.ButtonStart -> if (onClose != null) { onClose(); true } else false
+ else -> false
+ }
+ }
+ val body: @Composable ColumnScope.() -> Unit = {
+ ScNavDialogCapture(onBack = onBack)
+ content()
+ }
+ if (scrollable) {
+ val scroll = rememberScrollState()
+ ScScrollbar(scroll, modifier) {
+ // Inset the content from the right so the scrollbar (on the outer edge) doesn't overlap chips/rows.
+ Column(Modifier.verticalScroll(scroll).padding(end = 18.dp).then(keyHandling), content = body)
+ }
+ } else {
+ Column(modifier.then(keyHandling), content = body)
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ScMenuLabelEditor.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ScMenuLabelEditor.kt
new file mode 100644
index 0000000000..93ee3434ee
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ScMenuLabelEditor.kt
@@ -0,0 +1,156 @@
+package app.gamenative.ui.component.dialog
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import app.gamenative.steamcontroller.MenuLabelOverride
+import app.gamenative.steamcontroller.ScConfigStore
+import app.gamenative.steamcontroller.ScMenuLabelTool
+import app.gamenative.steamcontroller.ScMenuLabels
+import app.gamenative.ui.util.SnackbarManager
+
+/**
+ * Authors custom labels for radial/touch-menu slots (the overlay HUD text), persisted via [ScConfigStore] keyed
+ * by the game's config key and layered over the resolved config in [ScConfigStore.forKey]. Menus come from an
+ * imported `.vdf` or the built-in default (they aren't in the digital binding editor), so this lists whatever
+ * menus the resolved config actually has and lets the user rename each slot. Blank = keep the default label.
+ */
+@Composable
+fun ScMenuLabelEditorDialog(
+ storeKey: String,
+ onDismiss: () -> Unit,
+ // When set, scope the editor to a single menu (its set + location) — used by the bindings editor's inline
+ // "Rename slots…" affordance so labels live in one place. Null = list every menu (the full standalone view).
+ filterSetId: String? = null,
+ filterLocation: String? = null,
+) {
+ val context = LocalContext.current
+ val cfg = remember(storeKey) { ScConfigStore.rawConfig(context, storeKey) }
+ val allMenus = remember(storeKey) { cfg?.let { ScMenuLabelTool.enumerate(it) } ?: emptyList() }
+ val menus = remember(storeKey, filterSetId, filterLocation) {
+ if (filterSetId == null && filterLocation == null) allMenus
+ else allMenus.filter { it.setId == filterSetId && it.location.name == filterLocation }
+ }
+ val scoped = filterSetId != null || filterLocation != null
+ val multiSet = remember(storeKey) { (cfg?.sets?.size ?: 0) > 1 }
+ // Edited labels keyed by "setId|LOCATION|slot"; seeded from the stored overrides.
+ val edits = remember(storeKey) {
+ mutableStateMapOf().apply {
+ ScConfigStore.loadLabels(context, storeKey)?.overrides?.forEach {
+ put("${it.setId}|${it.location}|${it.slot}", it.label)
+ }
+ }
+ }
+
+ Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(0.95f).heightIn(max = 640.dp).padding(8.dp),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ val nav = remember { ScNavState() }
+ // Which slot's on-screen keyboard is open (its "setId|LOCATION|slot" key), driven by d-pad A or a tap.
+ var editingKey by remember { mutableStateOf(null) }
+ val doSave = {
+ val overrides = edits.entries.mapNotNull { (k, v) ->
+ val label = v.trim()
+ if (label.isBlank()) return@mapNotNull null
+ val parts = k.split("|")
+ if (parts.size != 3) return@mapNotNull null
+ MenuLabelOverride(parts[0], parts[1], parts[2].toIntOrNull() ?: return@mapNotNull null, label)
+ }
+ if (ScConfigStore.saveLabels(context, storeKey, ScMenuLabels(overrides))) {
+ SnackbarManager.show(if (overrides.isEmpty()) "Custom labels cleared" else "Labels saved")
+ onDismiss()
+ } else {
+ SnackbarManager.show("Could not save labels")
+ }
+ }
+ ScNavDialogColumn(nav, onBack = onDismiss, modifier = Modifier.padding(16.dp)) {
+ Text(if (scoped) "Rename slots" else "Menu labels", style = MaterialTheme.typography.titleLarge)
+ Text(
+ "Rename radial / touch-menu slots shown on the overlay. Leave blank to keep the default.",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
+ )
+ HorizontalDivider()
+
+ if (menus.isEmpty()) {
+ Text(
+ "This config has no radial or touch menus to label. Import a .vdf with menus, or use the " +
+ "built-in default's movement radial.",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(vertical = 16.dp),
+ )
+ } else {
+ val listScroll = rememberScrollState()
+ ScScrollbar(listScroll, Modifier.weight(1f, fill = false)) {
+ Column(modifier = Modifier.verticalScroll(listScroll)) {
+ var navLine = 0
+ for (menu in menus) {
+ val header = buildString {
+ append(menu.location.label); append(" · "); append(menu.kind)
+ if (multiSet) append(" (set ${menu.setId})")
+ }
+ ScSectionHeader(header)
+ menu.slotDefaults.forEachIndexed { i, default ->
+ val key = "${menu.setId}|${menu.location.name}|$i"
+ ScNavItem(nav, line = navLine++, modifier = Modifier.fillMaxWidth(), onActivate = { editingKey = key }) {
+ ScTextEditField(
+ label = "Slot ${i + 1} (default: $default)",
+ value = edits[key] ?: "",
+ onValueChange = { edits[key] = it },
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ editing = editingKey == key,
+ onEditingChange = { editingKey = if (it) key else null },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ScNavItem(nav, line = 9000, col = 0, onActivate = onDismiss) {
+ ScActionChip("Cancel", onClick = onDismiss)
+ }
+ Spacer(Modifier.width(8.dp))
+ if (menus.isNotEmpty()) {
+ ScNavItem(nav, line = 9000, col = 1, onActivate = doSave) {
+ ScActionChip("Save", onClick = doSave, filled = true)
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ScNavChoiceDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ScNavChoiceDialog.kt
new file mode 100644
index 0000000000..89a7402465
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ScNavChoiceDialog.kt
@@ -0,0 +1,65 @@
+package app.gamenative.ui.component.dialog
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+
+/**
+ * A controller-navigable replacement for a Material `DropdownMenu`: a small modal that lists [options] as a d-pad
+ * selectable vertical list (a real menu the user can see and pick from), instead of a popup window the SC nav bridge
+ * can't reach. Pushes onto the [ScNavDialogStack] (so the bridge dispatches d-pad/A here and B cancels), focusable
+ * root drives [ScNavState]. Picking an option calls [onPick] then [onDismiss].
+ */
+@Composable
+fun ScNavChoiceDialog(
+ title: String,
+ options: List>,
+ selected: T,
+ onPick: (T) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val nav = remember { ScNavState() }
+ Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(0.8f).padding(8.dp),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ ScNavDialogColumn(nav, onBack = onDismiss, modifier = Modifier.padding(16.dp)) {
+ Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp))
+ val listScroll = rememberScrollState()
+ ScScrollbar(listScroll, Modifier.heightIn(max = 420.dp)) {
+ Column(modifier = Modifier.verticalScroll(listScroll)) {
+ options.forEachIndexed { i, (value, lbl) ->
+ val pick = { onPick(value); onDismiss() }
+ val isSel = value == selected
+ ScNavItem(nav, i, modifier = Modifier.fillMaxWidth(), onActivate = pick) {
+ Text(
+ (if (isSel) "● " else "○ ") + lbl,
+ fontWeight = if (isSel) FontWeight.Bold else FontWeight.Normal,
+ color = if (isSel) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.fillMaxWidth().clickable { pick() }.padding(vertical = 12.dp, horizontal = 12.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ScNavDialogStack.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ScNavDialogStack.kt
new file mode 100644
index 0000000000..b320f10746
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ScNavDialogStack.kt
@@ -0,0 +1,62 @@
+package app.gamenative.ui.component.dialog
+
+import android.view.View
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.platform.LocalView
+import java.util.concurrent.CopyOnWriteArrayList
+
+/**
+ * A stack of the currently-open Steam Controller settings dialog windows. Each Compose `Dialog`/`AlertDialog` is a
+ * **separate window** with its own view tree, so the controller-nav bridge (which synthesizes DPAD/SELECT key events,
+ * see XServerScreen `scUiBridge.nav`) can't reach them by dispatching to the main Compose view — the events have to go
+ * to the top dialog's window view. Each SC settings dialog registers its window view + its dismiss callback here via
+ * [ScNavDialogCapture] while it's composed; the bridge dispatches DPAD/SELECT to [topView] and routes BACK through
+ * [back] (calling the top dialog's own dismiss — a synthetic KEYCODE_BACK does NOT reach a Compose dialog's
+ * onDismissRequest, which is why "B" didn't close anything before). This is the universal d-pad nav path; SC pad-mouse
+ * is layered on top of it.
+ */
+object ScNavDialogStack {
+ private class Entry(val view: View, val onBack: () -> Unit)
+
+ private val stack = CopyOnWriteArrayList()
+
+ /** The top (most-recently-opened) dialog window view, or null when no SC dialog is open. */
+ fun topView(): View? = stack.lastOrNull()?.view
+
+ /** True while any SC settings dialog is open. */
+ fun isActive(): Boolean = stack.isNotEmpty()
+
+ /** Close the top dialog via its own dismiss callback. Returns true if a dialog was open (and dismissed). */
+ fun back(): Boolean {
+ val top = stack.lastOrNull() ?: return false
+ top.onBack()
+ return true
+ }
+
+ fun push(view: View, onBack: () -> Unit) {
+ stack.removeAll { it.view === view }
+ stack.add(Entry(view, onBack))
+ }
+
+ fun remove(view: View) {
+ stack.removeAll { it.view === view }
+ }
+}
+
+/**
+ * Register the enclosing dialog's window view + dismiss callback in [ScNavDialogStack] for as long as it's composed,
+ * so controller-nav key events reach it and "B" closes it. Call once at the top of each SC settings dialog's content
+ * (the `LocalView` inside a Compose `Dialog`/`AlertDialog` is that dialog window's own view). [onBack] is the dialog's
+ * own dismiss (e.g. its `onDismissRequest`) — invoked when the controller's Back is pressed while this dialog is top.
+ */
+@Composable
+fun ScNavDialogCapture(onBack: () -> Unit) {
+ val view = LocalView.current
+ val latestOnBack = rememberUpdatedState(onBack)
+ DisposableEffect(view) {
+ ScNavDialogStack.push(view) { latestOnBack.value() }
+ onDispose { ScNavDialogStack.remove(view) }
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ScOverlayEditor.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ScOverlayEditor.kt
new file mode 100644
index 0000000000..8f8e9ffa34
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ScOverlayEditor.kt
@@ -0,0 +1,259 @@
+package app.gamenative.ui.component.dialog
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.draw.clip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import app.gamenative.steamcontroller.ScConfigStore
+import app.gamenative.steamcontroller.ScKeyboardOverlayView
+import app.gamenative.steamcontroller.ScKeyboardSpec
+import app.gamenative.steamcontroller.ScMenuLabelTool
+import app.gamenative.steamcontroller.ScMenuLocation
+import app.gamenative.steamcontroller.ScMenuOverlayView
+import app.gamenative.steamcontroller.ScMenuSpec
+import app.gamenative.steamcontroller.ScOverlayLayout
+import app.gamenative.steamcontroller.ScOverlayStore
+import app.gamenative.ui.util.SnackbarManager
+import kotlin.math.ceil
+import kotlin.math.sqrt
+
+/** Which overlay the editor is placing/sizing. */
+enum class ScOverlayTarget { MENU, KEYBOARD }
+
+/**
+ * Drag + pinch live editor for an overlay's placement/size ([ScOverlayLayout], persisted by [ScOverlayStore]).
+ * Shows the real overlay view over a neutral backdrop so the user sees exactly what they'll get in-game:
+ * one-finger drag repositions, pinch scales. [target] picks the menu HUD ([ScMenuOverlayView]) or the split
+ * keyboard ([ScKeyboardOverlayView]) — each has its own store namespace. [storeKey] is the game's container/appId
+ * for a per-game override, or [ScOverlayStore.DEFAULT_KEY] when [isShared] (the global default).
+ */
+@Composable
+fun ScOverlayEditorDialog(
+ storeKey: String,
+ isShared: Boolean,
+ target: ScOverlayTarget,
+ onDismiss: () -> Unit,
+) {
+ val context = LocalContext.current
+ val keyboard = target == ScOverlayTarget.KEYBOARD
+ // MENU only: the actual menus in this game's config (one per host surface, with their real kind + slot labels)
+ // so we place each menu 1-by-1 and preview exactly what it is. Empty = the config has no radial/touch menus.
+ val presentMenus = remember(storeKey) {
+ if (keyboard || isShared) emptyList()
+ else ScConfigStore.rawConfig(context, storeKey)?.let { ScMenuLabelTool.enumerate(it) }?.distinctBy { it.location } ?: emptyList()
+ }
+ // Placement selector = the present menus (no aggregate "All" — you place each individually). Default to the first.
+ val menuOptions: List = if (presentMenus.isEmpty()) listOf(null) else presentMenus.map { it.location }
+ // Keyed by storeKey so switching configs re-seeds the selection from the new store's menus (never a stale one).
+ var selectedMenu by remember(storeKey) { mutableStateOf(menuOptions.first()) }
+ fun stored(sel: ScMenuLocation?): ScOverlayLayout = when {
+ keyboard -> ScOverlayStore.forKeyboard(context, storeKey)
+ sel != null -> ScOverlayStore.forMenu(context, storeKey, sel.name)
+ else -> ScOverlayStore.forKey(context, storeKey)
+ }
+ var layout by remember { mutableStateOf(stored(selectedMenu)) }
+ // Reload the layout when the selected menu changes (so each menu edits its own stored placement).
+ LaunchedEffect(selectedMenu) { layout = stored(selectedMenu) }
+ val menuView = remember { if (keyboard) null else ScMenuOverlayView(context) }
+ val kbView = remember { if (keyboard) ScKeyboardOverlayView(context) else null }
+
+ // Build the real preview spec for the selected menu (its actual kind + slot labels); the generic sample only
+ // stands in when the config has no per-surface menus.
+ fun specFor(sel: ScMenuLocation?): ScMenuSpec {
+ val d = presentMenus.firstOrNull { it.location == sel } ?: return sampleSpec(radial = true)
+ val labels = d.slotDefaults.ifEmpty { listOf("1") }
+ return if (d.kind == "Radial") {
+ ScMenuSpec(ScMenuSpec.Kind.RADIAL, labels, 0, 0, 0)
+ } else {
+ val cols = ceil(sqrt(labels.size.toDouble())).toInt().coerceAtLeast(1)
+ val rows = ceil(labels.size.toDouble() / cols).toInt().coerceAtLeast(1)
+ ScMenuSpec(ScMenuSpec.Kind.GRID, labels, cols, rows, 0)
+ }
+ }
+
+ // Push the current layout + the selected menu's real sample into the overlay view whenever either changes.
+ LaunchedEffect(layout, selectedMenu) {
+ if (keyboard) {
+ kbView!!.setLayout(layout)
+ kbView.show(ScKeyboardSpec(leftCursor = 7, rightCursor = 2, shift = false))
+ } else {
+ menuView!!.setLayout(layout)
+ menuView.showMenu(specFor(selectedMenu))
+ }
+ }
+
+ // Auto-save: persist to the current scope on every user change (drag/pinch/stick/trigger/reset) — no Save button.
+ // Loading a different menu's stored layout (the LaunchedEffect above) does NOT save, so opening + backing out never
+ // writes a spurious override. ponytail: saves each gesture frame; SharedPreferences.apply() coalesces — debounce
+ // only if it ever matters.
+ fun saveLayout(sel: ScMenuLocation?, l: ScOverlayLayout) {
+ when {
+ keyboard -> ScOverlayStore.saveKeyboard(context, storeKey, l)
+ sel != null -> ScOverlayStore.saveMenu(context, storeKey, sel.name, l)
+ else -> ScOverlayStore.save(context, storeKey, l)
+ }
+ }
+ fun applyLayout(l: ScOverlayLayout) { val c = l.clamped(); layout = c; saveLayout(selectedMenu, c) }
+
+ // LB/RB cycle which menu you're placing (menuOptions from above). One entry = no cycle.
+ val cycleMenu: (Int) -> Unit = { d ->
+ if (menuOptions.size > 1) {
+ val i = menuOptions.indexOf(selectedMenu).coerceAtLeast(0)
+ selectedMenu = menuOptions[((i + d) % menuOptions.size + menuOptions.size) % menuOptions.size]
+ }
+ }
+ // Controller hotkey for the touch-only Reset (stick/d-pad move the overlay, so a button — not focus-nav — triggers
+ // it). Use-default stays touch-only (rare/destructive).
+ val doReset = { applyLayout(if (keyboard) ScOverlayStore.KEYBOARD_DEFAULT else ScOverlayLayout()) }
+ val selectedKind = presentMenus.firstOrNull { it.location == selectedMenu }?.kind
+
+ Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
+ // Focusable root (scCaptureFocus) so the SC bridge's synthetic d-pad / zoom (trigger) / bumper keys reach us —
+ // the controller is not an Android input device.
+ Box(
+ modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.82f))
+ .scCaptureFocus()
+ .onPreviewKeyEvent { ev ->
+ if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+ when (ev.key) {
+ // Left stick / d-pad = move (the bridge maps left-stick deflection to these d-pad keys).
+ Key.DirectionUp -> { applyLayout(layout.copy(cy = layout.cy - 0.02f)); true }
+ Key.DirectionDown -> { applyLayout(layout.copy(cy = layout.cy + 0.02f)); true }
+ Key.DirectionLeft -> { applyLayout(layout.copy(cx = layout.cx - 0.02f)); true }
+ Key.DirectionRight -> { applyLayout(layout.copy(cx = layout.cx + 0.02f)); true }
+ // Triggers = resize (RT bigger, LT smaller).
+ Key.ZoomIn -> { applyLayout(layout.copy(scale = layout.scale * 1.05f)); true }
+ Key.ZoomOut -> { applyLayout(layout.copy(scale = layout.scale / 1.05f)); true }
+ // Bumpers = switch which menu you're placing (menu editor only; no-op when there's one target).
+ Key.ButtonL1 -> { cycleMenu(-1); true }
+ Key.ButtonR1 -> { cycleMenu(1); true }
+ // Y = Reset (a touch-only action promoted to a button, since the stick moves the overlay).
+ Key.ButtonY -> { doReset(); true }
+ else -> false
+ }
+ },
+ ) {
+ ScNavDialogCapture(onBack = onDismiss) // B closes (the layout is already auto-saved)
+ AndroidView(
+ // Non-focusable so the overlay View can't steal focus from the Compose root — otherwise the bridge's
+ // synthetic d-pad/zoom/bumper keys stop arriving (they only fire while the focusable Box holds focus).
+ factory = { (menuView ?: kbView)!!.apply { isFocusable = false; isFocusableInTouchMode = false } },
+ modifier = Modifier.fillMaxSize().pointerInput(Unit) {
+ detectTransformGestures { _, pan, zoom, _ ->
+ val w = size.width.toFloat()
+ val h = size.height.toFloat()
+ applyLayout(
+ layout.copy(
+ scale = layout.scale * zoom,
+ cx = layout.cx + if (w > 0f) pan.x / w else 0f,
+ cy = layout.cy + if (h > 0f) pan.y / h else 0f,
+ ),
+ )
+ }
+ },
+ )
+
+ // Bottom control panel — semi-transparent so the preview shows through; rounded to match the SC menus.
+ // Auto-saves, so no Save/Cancel; the legend shows the controller controls (touch drag/pinch also work).
+ Column(
+ modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth()
+ .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.55f)).padding(12.dp),
+ ) {
+ if (!keyboard) {
+ Text(
+ when {
+ selectedMenu == null -> "No per-surface menus in this config — placing the shared HUD default."
+ else -> "Placing: ${selectedMenu?.label}${selectedKind?.let { " ($it)" } ?: ""}"
+ },
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
+ )
+ }
+ Text(
+ buildString {
+ append("Left stick: move · LT / RT: smaller / bigger")
+ if (menuOptions.size > 1) append(" · LB / RB: switch menu")
+ append(" · Y: reset · B: done")
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Text(
+ "Or drag / pinch on screen.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
+ ) {
+ ScActionChip("Reset", onClick = doReset)
+ if (!isShared) {
+ // Revert this scope to its fallback: a per-menu selection → the whole-HUD placement; the whole
+ // HUD → the global default; keyboard → the global keyboard default. Then close.
+ val perMenu = selectedMenu
+ val useDefault = {
+ when {
+ keyboard -> ScOverlayStore.clearKeyboard(context, storeKey)
+ perMenu != null -> ScOverlayStore.clearMenu(context, storeKey, perMenu.name)
+ else -> ScOverlayStore.clear(context, storeKey)
+ }
+ SnackbarManager.show(if (perMenu != null) "Using the HUD default" else "Using global default")
+ onDismiss()
+ }
+ ScActionChip(if (perMenu != null) "Use HUD default" else "Use global", onClick = useDefault)
+ }
+ }
+ }
+ }
+ }
+}
+
+/** A representative 8-slot menu so the user can judge size/position for both HUD kinds. */
+private fun sampleSpec(radial: Boolean): ScMenuSpec =
+ if (radial) {
+ ScMenuSpec(ScMenuSpec.Kind.RADIAL, listOf("↑", "↗", "→", "↘", "↓", "↙", "←", "↖"), 0, 0, 0)
+ } else {
+ ScMenuSpec(ScMenuSpec.Kind.GRID, listOf("1", "2", "3", "4", "5", "6", "7", "8"), 4, 2, 0)
+ }
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ScRootMenuDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ScRootMenuDialog.kt
new file mode 100644
index 0000000000..4d933166db
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ScRootMenuDialog.kt
@@ -0,0 +1,55 @@
+package app.gamenative.ui.component.dialog
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+
+/**
+ * The Steam Controller QuickMenu hub — the list of sub-editors (Manage Configs / Buttons & Bindings / …). Styled to
+ * match the bindings editor and the other SC menus: a flat rounded [Surface] card, [ScNavItem] rows with the rotating
+ * gradient selection ring + purple selected text, and a filled oval Back chip. Controller-navigable via
+ * [ScNavDialogColumn] (d-pad + A, B = back); the pad cursor also works. Each [items] entry is (label, action).
+ */
+@Composable
+fun ScRootMenuDialog(title: String, items: List Unit>>, onBack: () -> Unit) {
+ Dialog(onDismissRequest = onBack, properties = DialogProperties(usePlatformDefaultWidth = false)) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(0.95f).padding(8.dp),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ val nav = remember { ScNavState() }
+ ScNavDialogColumn(nav, onBack = onBack, modifier = Modifier.padding(16.dp)) {
+ Text(title, style = MaterialTheme.typography.titleLarge)
+ Spacer(Modifier.height(8.dp))
+ HorizontalDivider()
+ items.forEachIndexed { i, (label, action) ->
+ ScNavItem(nav, line = i, modifier = Modifier.fillMaxWidth(), onActivate = action) {
+ Text(
+ label,
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 8.dp),
+ )
+ }
+ }
+ HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
+ Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
+ ScNavItem(nav, line = items.size, onActivate = onBack) { ScActionChip("Back", onClick = onBack, filled = true) }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ScScrollbar.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ScScrollbar.kt
new file mode 100644
index 0000000000..e5dc9c1335
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ScScrollbar.kt
@@ -0,0 +1,218 @@
+package app.gamenative.ui.component.dialog
+
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import kotlin.math.roundToInt
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/**
+ * A `ScrollState`-driven port of GameNative's own scrollbar ([app.gamenative.ui.component.Scrollbar], used by the
+ * game-selection grid) so the SC editors match it thematically: an accent-primary thumb with a subtle vertical
+ * gradient + grab-handle over a faint track, auto-hiding after inactivity, expanding + draggable while touched.
+ *
+ * That component is bound to `LazyGridState`; ours use `Column(verticalScroll(ScrollState))`, and editing the
+ * upstream file would break our isolation — so this reproduces its look/behavior for a plain scroll container. Wrap
+ * the scrollable content: `ScScrollbar(scrollState, Modifier.weight(1f)) { Column(Modifier.verticalScroll(state)) { … } }`.
+ */
+@Composable
+fun ScScrollbar(
+ scrollState: ScrollState,
+ modifier: Modifier = Modifier,
+ thumbColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f),
+ trackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f),
+ thumbWidthCollapsed: Dp = 4.dp,
+ thumbWidthExpanded: Dp = 10.dp,
+ thumbMinHeightDp: Dp = 48.dp,
+ hideDelay: Long = 1500L,
+ content: @Composable BoxScope.() -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+
+ var isVisible by remember { mutableStateOf(false) }
+ var isDragging by remember { mutableStateOf(false) }
+ var isTouchScrolling by remember { mutableStateOf(false) }
+ var containerHeight by remember { mutableFloatStateOf(0f) }
+ var dragProgress by remember { mutableFloatStateOf(0f) }
+
+ val maxValue = scrollState.maxValue
+ val showScrollbar = maxValue > 0
+ val isScrollInProgress = scrollState.isScrollInProgress
+
+ // ScrollState makes the math simple: progress = value/maxValue; thumb length = visible fraction of content.
+ val scrollProgress = if (maxValue <= 0) 0f else (scrollState.value.toFloat() / maxValue).coerceIn(0f, 1f)
+ val thumbHeightRatio = if (maxValue <= 0 || containerHeight <= 0f) {
+ 1f
+ } else {
+ (containerHeight / (containerHeight + maxValue)).coerceIn(0.05f, 1f)
+ }
+
+ val isExpanded = isDragging || isTouchScrolling
+ val thumbWidth by animateDpAsState(
+ targetValue = if (isExpanded) thumbWidthExpanded else thumbWidthCollapsed,
+ animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium),
+ label = "thumbWidth",
+ )
+ val alpha by animateFloatAsState(
+ targetValue = if (isVisible || isDragging) 1f else 0f,
+ animationSpec = tween(durationMillis = 200),
+ label = "scrollbarAlpha",
+ )
+ val grabHandleAlpha by animateFloatAsState(
+ targetValue = if (isExpanded) 1f else 0f,
+ animationSpec = tween(durationMillis = 150),
+ label = "grabHandleAlpha",
+ )
+
+ LaunchedEffect(isScrollInProgress) {
+ if (isScrollInProgress && !isDragging) {
+ isTouchScrolling = true
+ } else if (!isScrollInProgress) {
+ delay(300)
+ isTouchScrolling = false
+ }
+ }
+
+ LaunchedEffect(scrollState.value) {
+ if (showScrollbar) {
+ isVisible = true
+ delay(hideDelay)
+ if (!isDragging && !isTouchScrolling) isVisible = false
+ }
+ }
+
+ Box(modifier = modifier.fillMaxSize()) {
+ content()
+
+ if (showScrollbar && alpha > 0f) {
+ val density = LocalDensity.current
+ val thumbMinHeightPx = with(density) { thumbMinHeightDp.toPx() }
+ val thumbHeightPx = (containerHeight * thumbHeightRatio).coerceAtLeast(thumbMinHeightPx)
+ val maxOffset = (containerHeight - thumbHeightPx).coerceAtLeast(0f)
+ val thumbHeightDp = with(density) { thumbHeightPx.toDp() }
+
+ val effectiveProgress = if (isDragging) dragProgress else scrollProgress
+ val thumbOffset = effectiveProgress * maxOffset
+
+ Box(
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .fillMaxHeight()
+ .width(24.dp)
+ .padding(end = 4.dp)
+ .alpha(alpha)
+ .onSizeChanged { containerHeight = it.height.toFloat() }
+ .pointerInput(Unit) {
+ detectTapGestures { offset ->
+ val target = (offset.y / containerHeight).coerceIn(0f, 1f)
+ scope.launch { scrollState.animateScrollTo((target * maxValue).roundToInt()) }
+ }
+ }
+ .pointerInput(maxValue) {
+ detectDragGestures(
+ onDragStart = { dragProgress = scrollProgress; isDragging = true; isVisible = true },
+ onDragEnd = {
+ isDragging = false
+ scope.launch { delay(hideDelay); if (!isTouchScrolling) isVisible = false }
+ },
+ onDragCancel = {
+ isDragging = false
+ scope.launch { delay(hideDelay); if (!isTouchScrolling) isVisible = false }
+ },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ val deltaProgress = dragAmount.y / maxOffset.coerceAtLeast(1f)
+ dragProgress = (dragProgress + deltaProgress).coerceIn(0f, 1f)
+ // dispatchRawDelta is the non-suspending pointer-scroll API — no coroutine per
+ // drag event (unlike scrollTo). Verify scroll direction/scale on-device.
+ val target = (dragProgress * maxValue).roundToInt()
+ scrollState.dispatchRawDelta((target - scrollState.value).toFloat())
+ },
+ )
+ },
+ ) {
+ // Track
+ Box(
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .fillMaxHeight()
+ .width(thumbWidth)
+ .clip(RoundedCornerShape(50))
+ .background(trackColor),
+ )
+ // Thumb
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .offset { IntOffset(0, thumbOffset.roundToInt()) }
+ .width(thumbWidth)
+ .height(thumbHeightDp)
+ .clip(RoundedCornerShape(50))
+ .background(
+ brush = Brush.verticalGradient(
+ colors = listOf(thumbColor, thumbColor.copy(alpha = thumbColor.alpha * 0.8f)),
+ ),
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (grabHandleAlpha > 0f) {
+ Column(
+ modifier = Modifier.alpha(grabHandleAlpha),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ repeat(3) {
+ Box(
+ modifier = Modifier
+ .width(6.dp)
+ .height(1.5.dp)
+ .clip(RoundedCornerShape(50))
+ .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ScSelectionRing.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ScSelectionRing.kt
new file mode 100644
index 0000000000..f4ab3466ca
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ScSelectionRing.kt
@@ -0,0 +1,100 @@
+package app.gamenative.ui.component.dialog
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.tween
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Paint
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.drawOutline
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * The QuickMenu's rotating gradient focus ring (primary↔tertiary sweep), but driven **synchronously** by a [selected]
+ * boolean instead of an [androidx.compose.foundation.interaction.InteractionSource].
+ *
+ * We can't reuse `app.gamenative.ui.component.focusRing` directly for our (line,col) nav model: adapting our boolean
+ * to a focus interaction requires an async `emit`, which races under fast d-pad auto-repeat — a cancelled `Unfocus`
+ * left the ring stuck lit on items we'd already scrolled past. Reading [selected] in the draw phase (via [Animatable])
+ * is race-free: the ring appears/clears exactly with the selection. Mirrors focusRing's masked-sweep technique: a
+ * static stroke clipped to [shape] acts as a mask that the rotating sweep is painted through.
+ */
+@Composable
+fun Modifier.scSelectionRing(
+ selected: Boolean,
+ shape: Shape,
+ width: Dp = 2.dp,
+ durationMillis: Int = 5000,
+): Modifier {
+ // Created unconditionally (stable slot) so the ring can't flicker on recompose; spins only while selected.
+ val angle = remember { Animatable(0f) }
+ LaunchedEffect(selected) {
+ if (selected) {
+ angle.animateTo(
+ targetValue = 360f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart,
+ ),
+ )
+ } else {
+ angle.snapTo(0f)
+ }
+ }
+
+ if (!selected) return this
+
+ // first == last so the sweep loops seamlessly; only primary + tertiary (secondary is near-black).
+ val colors = listOf(
+ MaterialTheme.colorScheme.primary,
+ MaterialTheme.colorScheme.tertiary,
+ MaterialTheme.colorScheme.primary,
+ )
+ val strokePx = with(LocalDensity.current) { width.toPx() }
+
+ return drawWithCache {
+ val outline = shape.createOutline(size, layoutDirection, this)
+ val bounds = Rect(Offset.Zero, size)
+ val center = bounds.center
+ val sweep = Brush.sweepGradient(colors, center)
+ val layerPaint = Paint()
+ val clipPath = Path().apply {
+ when (val o = outline) {
+ is Outline.Rectangle -> addRect(o.rect)
+ is Outline.Rounded -> addRoundRect(o.roundRect)
+ is Outline.Generic -> addPath(o.path)
+ }
+ }
+ onDrawWithContent {
+ drawContent()
+ val canvas = drawContext.canvas
+ canvas.saveLayer(bounds, layerPaint)
+ canvas.clipPath(clipPath)
+ // Stroke at 2× width; the clipped-off outer half leaves an inward border of `width`.
+ drawOutline(outline, color = Color.Black, style = Stroke(strokePx * 2f))
+ rotate(angle.value, pivot = center) {
+ drawCircle(brush = sweep, radius = size.maxDimension, blendMode = BlendMode.SrcIn)
+ }
+ canvas.restore()
+ }
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ScTextEditField.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ScTextEditField.kt
new file mode 100644
index 0000000000..2f7ebd1f79
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/ScTextEditField.kt
@@ -0,0 +1,185 @@
+package app.gamenative.ui.component.dialog
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Button
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+
+/**
+ * A text field for the in-game Steam-Controller editors that does NOT pop the Android system IME (which, on the
+ * cover display, covers the whole screen and leaves nowhere for the pad cursor to move to). Instead it shows a
+ * read-only value that, when tapped, opens a compact on-screen keyboard *inside a dialog* — the user types by
+ * clicking keys with the pad cursor (right trackpad + click, with haptics), exactly like every other editor control.
+ *
+ * [onValueChange] fires only on Done (B / Cancel discards), matching the "focus → edit → accept/cancel" model.
+ */
+@Composable
+fun ScTextEditField(
+ label: String,
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ /** Optional controlled editing state, so a parent (e.g. d-pad "A") can open the keyboard. Null = self-managed. */
+ editing: Boolean = false,
+ onEditingChange: ((Boolean) -> Unit)? = null,
+ /** When false, drop the stacked label (it becomes the empty-value placeholder) so the field is a single compact
+ * box — used where a selection outline wraps it and the label would make that outline look oversized. */
+ showLabel: Boolean = true,
+) {
+ var internalEditing by remember { mutableStateOf(false) }
+ val isEditing = if (onEditingChange != null) editing else internalEditing
+ val setEditing: (Boolean) -> Unit = onEditingChange ?: { internalEditing = it }
+ Column(modifier) {
+ if (showLabel) Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = RoundedCornerShape(16.dp), // match the nav-selection outline radius
+ modifier = Modifier.fillMaxWidth().then(if (showLabel) Modifier.padding(top = 2.dp) else Modifier).clickable { setEditing(true) },
+ ) {
+ Text(
+ value.ifBlank { if (showLabel) "Tap to edit" else label },
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
+ color = if (value.isBlank()) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ if (isEditing) {
+ ScOnScreenKeyboardDialog(
+ label = label,
+ initial = value,
+ onCancel = { setEditing(false) },
+ onDone = { onValueChange(it); setEditing(false) },
+ )
+ }
+}
+
+/** Compact cursor-clickable keyboard. No system IME — types into a local string committed on Done. Also used as a
+ * standalone name-entry prompt (e.g. the config manager's duplicate/rename). */
+@Composable
+fun ScOnScreenKeyboardDialog(
+ label: String,
+ initial: String,
+ onCancel: () -> Unit,
+ onDone: (String) -> Unit,
+) {
+ var text by remember { mutableStateOf(initial) }
+ var shift by remember { mutableStateOf(false) }
+ val kbNav = remember { ScNavState() }
+ Dialog(onDismissRequest = onCancel, properties = DialogProperties(usePlatformDefaultWidth = false)) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(0.96f).padding(8.dp),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp)
+ .scCaptureFocus()
+ .onPreviewKeyEvent { ev ->
+ if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+ when (ev.key) {
+ Key.DirectionDown -> { kbNav.moveVertical(1); true }
+ Key.DirectionUp -> { kbNav.moveVertical(-1); true }
+ Key.DirectionRight -> { kbNav.moveHorizontal(1); true }
+ Key.DirectionLeft -> { kbNav.moveHorizontal(-1); true }
+ Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.ButtonA -> { kbNav.activate(); true }
+ else -> false
+ }
+ },
+ ) {
+ // Put this keyboard on the SC nav stack so the pad cursor taps land here and B cancels it.
+ ScNavDialogCapture(onBack = onCancel)
+ Text("Edit: $label", style = MaterialTheme.typography.titleMedium)
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = RoundedCornerShape(8.dp),
+ modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
+ ) {
+ Text(
+ text.ifEmpty { " " },
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ val rows = listOf("1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm")
+ rows.forEachIndexed { r, row ->
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(3.dp)) {
+ row.forEachIndexed { c, ch0 ->
+ val ch = if (shift) ch0.uppercaseChar() else ch0
+ val type = { text += ch }
+ ScNavItem(kbNav, r, c, modifier = Modifier.weight(1f), onActivate = type) {
+ KeyCap(ch.toString(), Modifier.fillMaxWidth(), type)
+ }
+ }
+ }
+ Spacer(Modifier.height(3.dp))
+ }
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(3.dp)) {
+ val toggleShift = { shift = !shift }
+ val space = { text += " " }
+ val backspace = { if (text.isNotEmpty()) text = text.dropLast(1) }
+ ScNavItem(kbNav, 4, 0, modifier = Modifier.weight(1.4f), onActivate = toggleShift) { KeyCap(if (shift) "⇧ ✓" else "⇧", Modifier.fillMaxWidth(), toggleShift) }
+ ScNavItem(kbNav, 4, 1, modifier = Modifier.weight(3f), onActivate = space) { KeyCap("Space", Modifier.fillMaxWidth(), space) }
+ ScNavItem(kbNav, 4, 2, modifier = Modifier.weight(1.4f), onActivate = backspace) { KeyCap("⌫", Modifier.fillMaxWidth(), backspace) }
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val done = { onDone(text) }
+ ScNavItem(kbNav, 5, 0, onActivate = onCancel) { TextButton(onClick = onCancel) { Text("Cancel") } }
+ Spacer(Modifier.width(8.dp))
+ ScNavItem(kbNav, 5, 1, onActivate = done) { Button(onClick = done) { Text("Done") } }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun KeyCap(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
+ Box(
+ modifier = modifier
+ .height(46.dp)
+ .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(6.dp))
+ .clickable { onClick() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ label,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+}
diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/SteamControllerBindingEditor.kt b/app/src/main/java/app/gamenative/ui/component/dialog/SteamControllerBindingEditor.kt
new file mode 100644
index 0000000000..8460e84369
--- /dev/null
+++ b/app/src/main/java/app/gamenative/ui/component/dialog/SteamControllerBindingEditor.kt
@@ -0,0 +1,1554 @@
+package app.gamenative.ui.component.dialog
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.ui.draw.clip
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import app.gamenative.steamcontroller.AnalogMode
+import app.gamenative.steamcontroller.EditActivator
+import app.gamenative.steamcontroller.EditAnalog
+import app.gamenative.steamcontroller.EditBinding
+import app.gamenative.steamcontroller.EditCurve
+import app.gamenative.steamcontroller.EditGyro
+import app.gamenative.steamcontroller.ScMenuNav
+import app.gamenative.steamcontroller.TritonProtocol
+import app.gamenative.steamcontroller.EditHaptics
+import app.gamenative.steamcontroller.EditMenuSlot
+import app.gamenative.steamcontroller.EditTrigger
+import app.gamenative.steamcontroller.GyroEditMode
+import app.gamenative.steamcontroller.OutputKind
+import app.gamenative.steamcontroller.TriggerEditMode
+import app.gamenative.steamcontroller.ScConfigStore
+import app.gamenative.steamcontroller.ScTuningStore
+import app.gamenative.steamcontroller.ScMenuLabelTool
+import app.gamenative.steamcontroller.ScMenuLocation
+import app.gamenative.steamcontroller.ScEditableConfig
+import app.gamenative.steamcontroller.ScEditableProfile
+import app.gamenative.steamcontroller.ScEditableSet
+import app.gamenative.steamcontroller.ScProfile
+import app.gamenative.steamcontroller.ScSource
+import com.winlator.xserver.Pointer
+import com.winlator.xserver.XKeycode
+
+/**
+ * Built-in Steam Controller binding editor (newprompt "Next focus" #1b). A purpose-built editor for the SC's
+ * digital sources — distinct from GameNative's touch/XInput controls editor, which doesn't model the SC's
+ * paddles/grips/pad-clicks or per-binding activators. Loads/saves an [ScEditableProfile] via [ScConfigStore]
+ * keyed by the game's container id; the live driver picks it up on next launch.
+ *
+ * Scope (MVP): per-source output (key / pad button / pad d-pad / mouse button / unbound) + activator. Analog
+ * sources (sticks, trackpad motion, triggers, gyro) inherit the built-in default for now; action sets / layers
+ * / mode-shift come from importing a `.vdf`.
+ */
+@Composable
+fun SteamControllerBindingEditorDialog(containerId: String, onDismiss: () -> Unit) {
+ val context = LocalContext.current
+ // Phase 5d: the editor authors a whole multi-action-set config. `profile` below is the currently-edited set.
+ var config by remember {
+ mutableStateOf(
+ ScConfigStore.loadEditableConfig(context, containerId)
+ ?: ScEditableConfig.fromSingle(ScEditableProfile.from(ScProfile.default())),
+ )
+ }
+ var activeSetId by remember { mutableStateOf(config.defaultSetId) }
+ val activeIdx = config.sets.indexOfFirst { it.id == activeSetId }.let { if (it < 0) 0 else it }
+ val profile = config.sets[activeIdx].profile
+ fun setProfile(p: ScEditableProfile) {
+ config = config.copy(sets = config.sets.toMutableList().also { it[activeIdx] = it[activeIdx].copy(profile = p) })
+ }
+ // Auto-save: persist every change immediately — no Save button. The live driver re-reads the config on editor
+ // close (XServerScreen's scEditorDismiss -> tritonMapper.reload()), so B / Start applies it to the running game.
+ // ponytail: saves on every edit (incl. each keystroke); fine for a small config, debounce if it ever matters.
+ LaunchedEffect(config) { ScConfigStore.saveEditableConfig(context, containerId, config) }
+ val closeEditor = {
+ ScConfigStore.saveEditableConfig(context, containerId, config) // belt-and-suspenders vs a same-frame close
+ onDismiss()
+ }
+ var editing by remember { mutableStateOf(null) }
+ var editingSurface by remember { mutableStateOf(null) }
+ var editingTrigger by remember { mutableStateOf(null) }
+ var editingGyro by remember { mutableStateOf(false) }
+ var editingHaptics by remember { mutableStateOf(false) }
+ var showHelp by remember { mutableStateOf(false) }
+ // Menus from an imported .vdf / the built-in default aren't authored in this editor (they resolve as INHERIT),
+ // so their slot labels can't be renamed inline — enumerate them and offer a scoped "Rename slots…" affordance
+ // on the matching surface, keyed by (setId, location) and backed by the label overlay (ScMenuLabels).
+ val rawMenus = remember(containerId) {
+ runCatching { ScConfigStore.rawConfig(context, containerId)?.let { ScMenuLabelTool.enumerate(it) } }.getOrNull() ?: emptyList()
+ }
+ var renamingMenu by remember { mutableStateOf?>(null) }
+
+ Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(0.95f).heightIn(max = 640.dp).padding(8.dp),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ // Controller d-pad here SCROLLS the (long) content list: Compose's focus traversal (moveFocus) proved
+ // unreliable inside this Dialog's FlowRow+scroll layout — the key was consumed but focus never moved — so
+ // d-pad up/down drives the hoisted [scrollState] directly (guaranteed + visible), and the pad cursor does
+ // the pointing/clicking. onPreviewKeyEvent only fires while the root holds focus, so we hammer requestFocus
+ // until something in the dialog is focused (a freshly-opened Dialog window doesn't focus on the first frame).
+ val scrollState = rememberScrollState()
+ val nav = remember { ScNavState() }
+ // LB/RB cycle the active action set (sets are tab-like), mirroring the picker's bumper-tab pattern.
+ fun cycleSet(d: Int) {
+ if (config.sets.size <= 1) return
+ val i = config.sets.indexOfFirst { it.id == activeSetId }.coerceAtLeast(0)
+ activeSetId = config.sets[((i + d) % config.sets.size + config.sets.size) % config.sets.size].id
+ }
+ // LB/RB switch action set · Y = Help · Start = save+close; all d-pad/A/B/focus wiring is ScNavDialogColumn.
+ ScNavDialogColumn(
+ nav,
+ onBack = closeEditor,
+ onBumper = { cycleSet(it) },
+ onHelp = { showHelp = !showHelp },
+ onClose = closeEditor,
+ modifier = Modifier.padding(16.dp),
+ ) {
+ // Compact header: action-set chips sit at the top (title + long description moved out → Y = Help);
+ // the Set name / Layer / Default / Delete row moved into the scroll list so it doesn't sit fixed.
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text("Action sets", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
+ ScButtonGlyph(TritonProtocol.BTN_LBUMPER, size = 22.dp)
+ ScButtonGlyph(TritonProtocol.BTN_RBUMPER, size = 22.dp)
+ Text("switch set", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.weight(1f))
+ TextButton(onClick = { showHelp = true }) {
+ ScButtonGlyph(TritonProtocol.BTN_Y, size = 20.dp)
+ Spacer(Modifier.width(6.dp))
+ Text("Help")
+ }
+ }
+ CmdFlow {
+ config.sets.forEachIndexed { i, s ->
+ val isDef = s.id == config.defaultSetId
+ val selectSet = { activeSetId = s.id }
+ ScNavItem(nav, line = 0, col = i, onActivate = selectSet) {
+ ScChip((s.name.ifBlank { "Set ${i + 1}" }) + if (isDef) " ★" else "", selected = s.id == activeSetId, onClick = selectSet)
+ }
+ }
+ val addSet = {
+ val id = config.nextSetId()
+ config = config.copy(sets = config.sets + ScEditableSet(id = id, name = "Set ${config.sets.size + 1}"))
+ activeSetId = id
+ }
+ ScNavItem(nav, line = 0, col = config.sets.size, onActivate = addSet) {
+ ScChip("+ Add", selected = false, onClick = addSet)
+ }
+ }
+ HorizontalDivider(modifier = Modifier.padding(top = 6.dp))
+
+ ScScrollbar(
+ scrollState = scrollState,
+ modifier = Modifier.weight(1f, fill = false),
+ ) {
+ // Inset the rows from the right so the scrollbar (on the outer edge) doesn't overlap them.
+ Column(modifier = Modifier.verticalScroll(scrollState).fillMaxWidth().padding(end = 16.dp)) {
+ // Set name + Layer / Default / Delete for the active set — first in the scroll list (line 1) so
+ // it scrolls away instead of sitting in the fixed header. Set chips stay pinned at the top (line 0).
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ var editingName by remember { mutableStateOf(false) }
+ ScNavItem(nav, line = 1, col = 0, modifier = Modifier.weight(1f), onActivate = { editingName = true }) {
+ ScTextEditField(
+ label = "Set name",
+ value = config.sets[activeIdx].name,
+ onValueChange = { newName ->
+ config = config.copy(
+ sets = config.sets.toMutableList().also { it[activeIdx] = it[activeIdx].copy(name = newName) },
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ editing = editingName,
+ onEditingChange = { editingName = it },
+ showLabel = false,
+ )
+ }
+ Spacer(Modifier.width(8.dp))
+ // Mark this set as an action LAYER (a partial overlay pushed by a "hold/add layer" binding)
+ // vs a base action set. A layer overrides only the surfaces it defines + its buttons.
+ val isLayer = config.sets[activeIdx].isLayer
+ val toggleLayer = {
+ config = config.copy(sets = config.sets.toMutableList().also { it[activeIdx] = it[activeIdx].copy(isLayer = !isLayer) })
+ }
+ val makeDefault = { config = config.copy(defaultSetId = activeSetId) }
+ val deleteSet = {
+ if (config.sets.size > 1) {
+ val removedId = config.sets[activeIdx].id
+ val newSets = config.sets.filterIndexed { idx, _ -> idx != activeIdx }
+ val newDefault = if (config.defaultSetId == removedId) newSets.first().id else config.defaultSetId
+ config = config.copy(sets = newSets, defaultSetId = newDefault)
+ activeSetId = newSets.first().id
+ }
+ }
+ val isDefault = config.defaultSetId == activeSetId
+ ScNavItem(nav, line = 1, col = 1, onActivate = toggleLayer) {
+ ScChip(if (isLayer) "Layer ✓" else "Layer", selected = isLayer, onClick = toggleLayer)
+ }
+ ScNavItem(nav, line = 1, col = 2, onActivate = makeDefault) {
+ ScChip(if (isDefault) "Default ✓" else "Default", selected = isDefault, onClick = makeDefault, enabled = !isDefault)
+ }
+ ScNavItem(nav, line = 1, col = 3, onActivate = deleteSet) {
+ ScChip("Delete", selected = false, onClick = deleteSet, enabled = config.sets.size > 1)
+ }
+ }
+ var navLine = 2 // lines 0=set chips, 1=set name / Layer/Default/Delete; source list starts at 2
+ var lastGroup = ""
+ // Stick/pad CLICK binds fold into their analog surface's editor, and trigger full-pull clicks
+ // fold into the Left/Right Trigger editors (below) — not shown as separate buttons here.
+ val foldedClicks = (AnalogSurface.ALL.map { it.clickSource } + TriggerSide.entries.map { it.clickSource }).toSet()
+ for (src in ScSource.entries) {
+ if (src in foldedClicks) continue
+ if (src.group != lastGroup) {
+ lastGroup = src.group
+ ScSectionHeader(src.group)
+ }
+ val open = { editing = src }
+ ScNavItem(nav, line = navLine, modifier = Modifier.fillMaxWidth(), onActivate = open) {
+ SourceRow(src, profile.buttons[src.name] ?: EditBinding(), onClick = open)
+ }
+ navLine++
+ }
+
+ var lastIsStick: Boolean? = null
+ for (surface in AnalogSurface.ALL) {
+ if (surface.isStick != lastIsStick) {
+ lastIsStick = surface.isStick
+ ScSectionHeader(if (surface.isStick) "Sticks" else "Pads")
+ }
+ val open = { editingSurface = surface }
+ // An inherited menu here (base .vdf/default has one, but the editor doesn't author it) →
+ // its slots aren't inline-editable, so surface a scoped "Rename slots…" affordance.
+ val inheritedMenu = surface.get(profile) == null &&
+ rawMenus.any { it.setId == activeSetId && it.location == surface.location }
+ ScNavItem(nav, line = navLine, col = 0, modifier = Modifier.fillMaxWidth(), onActivate = open) {
+ AnalogSurfaceRow(surface, surface.get(profile), onClick = open)
+ }
+ if (inheritedMenu) {
+ val rename = { renamingMenu = activeSetId to surface.location }
+ ScNavItem(nav, line = navLine, col = 1, modifier = Modifier.fillMaxWidth().padding(start = 16.dp), onActivate = rename) {
+ TextButton(onClick = rename) { Text("Rename slots…") }
+ }
+ }
+ navLine++
+ }
+
+ ScSectionHeader("Triggers")
+ for (side in TriggerSide.entries) {
+ val open = { editingTrigger = side }
+ ScNavItem(nav, line = navLine, modifier = Modifier.fillMaxWidth(), onActivate = open) {
+ DetailRow(side.label, summarizeTrigger(side.get(profile)), onClick = open)
+ }
+ navLine++
+ }
+
+ ScSectionHeader("Gyro")
+ val openGyro = { editingGyro = true }
+ ScNavItem(nav, line = navLine, modifier = Modifier.fillMaxWidth(), onActivate = openGyro) {
+ DetailRow("Gyro", summarizeGyro(profile.gyro), onClick = openGyro)
+ }
+ navLine++
+
+ ScSectionHeader("Haptics")
+ val openHaptics = { editingHaptics = true }
+ ScNavItem(nav, line = navLine, modifier = Modifier.fillMaxWidth(), onActivate = openHaptics) {
+ DetailRow("Haptics", summarizeHaptics(profile.haptics), onClick = openHaptics)
+ }
+ }
+ }
+
+ // No Save/Cancel/Reset row — changes auto-save on every edit (see the LaunchedEffect above); B or Start
+ // returns to the game and the driver re-reads the config. A little bottom breathing room for the list.
+ Spacer(Modifier.height(8.dp))
+ }
+ }
+ }
+
+ editing?.let { src ->
+ BindingPickerDialog(
+ title = src.label,
+ current = profile.buttons[src.name] ?: EditBinding(),
+ actionSets = config.sets.map { it.id to it.name },
+ onDismiss = { editing = null },
+ onApply = { newBinding ->
+ setProfile(profile.copy(buttons = profile.buttons + (src.name to newBinding)))
+ editing = null
+ },
+ )
+ }
+
+ editingSurface?.let { surface ->
+ val clickName = surface.clickSource.name
+ AnalogPickerDialog(
+ surface = surface,
+ current = surface.get(profile),
+ clickBinding = profile.buttons[clickName] ?: EditBinding(),
+ actionSets = config.sets.map { it.id to it.name },
+ onDismiss = { editingSurface = null },
+ onApply = { newAnalog, newClick ->
+ setProfile(surface.set(profile, newAnalog).copy(buttons = profile.buttons + (clickName to newClick)))
+ editingSurface = null
+ },
+ )
+ }
+
+ renamingMenu?.let { (setId, loc) ->
+ ScMenuLabelEditorDialog(
+ storeKey = containerId,
+ filterSetId = setId,
+ filterLocation = loc.name,
+ onDismiss = { renamingMenu = null },
+ )
+ }
+
+ editingTrigger?.let { side ->
+ val clickName = side.clickSource.name
+ TriggerPickerDialog(
+ side = side,
+ current = side.get(profile) ?: EditTrigger(axis = side.defaultAxis),
+ clickBinding = profile.buttons[clickName] ?: EditBinding(),
+ actionSets = config.sets.map { it.id to it.name },
+ onDismiss = { editingTrigger = null },
+ onApply = { t, click ->
+ setProfile(side.set(profile, t).copy(buttons = profile.buttons + (clickName to click)))
+ editingTrigger = null
+ },
+ )
+ }
+
+ if (editingGyro) {
+ GyroPickerDialog(
+ current = profile.gyro ?: EditGyro(),
+ onDismiss = { editingGyro = false },
+ onApply = { g -> setProfile(profile.copy(gyro = g)); editingGyro = false },
+ )
+ }
+
+ if (editingHaptics) {
+ HapticsDialog(
+ current = profile.haptics ?: EditHaptics(),
+ onDismiss = { editingHaptics = false },
+ onApply = { h -> setProfile(profile.copy(haptics = h)); editingHaptics = false },
+ )
+ }
+ if (showHelp) {
+ AlertDialog(
+ onDismissRequest = { showHelp = false },
+ containerColor = MaterialTheme.colorScheme.surface,
+ tonalElevation = 0.dp,
+ shape = MaterialTheme.shapes.large,
+ title = { Text("Steam Controller bindings — help") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ // Navigation controls are rendered from the fixed [ScMenuNav] table using the app's own Xbox
+ // glyphs, so they always match what the buttons actually do (and look like the rest of the app).
+ Text("Navigating this menu", style = MaterialTheme.typography.labelLarge)
+ ScNavHelpDirectionsRow()
+ ScMenuNav.controls.forEach { ScNavHelpRow(it) }
+ Spacer(Modifier.height(4.dp))
+ Text(
+ "Tap a button row to rebind it; tap a stick / trackpad / trigger / gyro / haptics row to change " +
+ "its behavior. The Set name row (top of the list) renames the active set and marks it a Layer, " +
+ "the launch Default, or deletes it. Inherited menus (from the built-in default / imported .vdf) " +
+ "show a “Rename slots…” action; authored menus rename slots inside the behavior editor.",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ },
+ confirmButton = { TextButton(onClick = { showHelp = false }) { Text("Close") } },
+ )
+ }
+}
+
+// ---- Phase 5: triggers / gyro / haptics ----
+
+/** A reusable label + summary row (used by triggers/gyro/haptics, mirroring [AnalogSurfaceRow]). */
+@Composable
+private fun DetailRow(label: String, summary: String, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
+ Text(summary, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+}
+
+/** Which physical trigger an [EditTrigger] row authors, with get/set on [ScEditableProfile] + its default axis. */
+private enum class TriggerSide(val label: String, val defaultAxis: String, val clickSource: ScSource) {
+ LEFT("Left Trigger", "GAMEPAD_L2", ScSource.LEFT_TRIGGER_CLICK),
+ RIGHT("Right Trigger", "GAMEPAD_R2", ScSource.RIGHT_TRIGGER_CLICK);
+
+ fun get(p: ScEditableProfile): EditTrigger? = if (this == LEFT) p.leftTrigger else p.rightTrigger
+ fun set(p: ScEditableProfile, t: EditTrigger?): ScEditableProfile =
+ if (this == LEFT) p.copy(leftTrigger = t) else p.copy(rightTrigger = t)
+}
+
+private fun summarizeTrigger(t: EditTrigger?): String = when (t?.mode) {
+ null -> "Inherit (default)"
+ TriggerEditMode.AXIS -> "Axis: ${t.axis.removePrefix("GAMEPAD_")}"
+ TriggerEditMode.STAGED -> "Staged (soft/full)"
+}
+
+private fun summarizeGyro(g: EditGyro?): String {
+ fun g2(s: String) = s.lowercase().replace('_', ' ')
+ val act = g2(g?.activation ?: "ENABLE")
+ return when (g?.mode) {
+ null -> "Inherit (default)"
+ GyroEditMode.OFF -> "Off"
+ GyroEditMode.MOUSE -> "Mouse ($act, ${g2(g.gate)})"
+ GyroEditMode.JOYSTICK -> "Joystick (${g.outputStick.lowercase()}, $act, ${g2(g.gate)})"
+ }
+}
+
+private fun summarizeHaptics(h: EditHaptics?): String = when {
+ h == null -> "Inherit (default)"
+ !h.enabled -> "Off"
+ else -> "On" + (if (!h.leftPadEnabled || !h.rightPadEnabled) " (partial)" else "")
+}
+
+@Composable
+private fun TriggerPickerDialog(
+ side: TriggerSide,
+ current: EditTrigger,
+ clickBinding: EditBinding,
+ actionSets: List>,
+ onDismiss: () -> Unit,
+ onApply: (EditTrigger?, EditBinding) -> Unit,
+) {
+ val inherit = "INHERIT"
+ val modeOptions = listOf(inherit to "Inherit / keep current") + TriggerEditMode.entries.map { it.name to it.uiLabel() }
+ // Reflect the trigger's actual mode (Axis/Staged) so opening a configured trigger shows it, not "Inherit".
+ var modeKey by remember { mutableStateOf(current.mode.name) }
+ var axis by remember { mutableStateOf(current.axis) }
+ var soft by remember { mutableIntStateOf(current.softThresholdPct) }
+ var full by remember { mutableIntStateOf(current.fullThresholdPct) }
+ // The command each stage fires at its threshold (Steam's soft/full-pull binds). Authored via the shared picker.
+ var softCmd by remember { mutableStateOf(current.soft) }
+ var fullCmd by remember { mutableStateOf(current.full) }
+ var pickingStage by remember { mutableStateOf(null) } // "SOFT" | "FULL" | null
+ // The trigger's hardware full-pull digital click, folded into this editor (was a separate button row).
+ var clickBind by remember { mutableStateOf(clickBinding) }
+ var editingClick by remember { mutableStateOf(false) }
+ val mode = runCatching { TriggerEditMode.valueOf(modeKey) }.getOrNull()
+ val nav = remember { ScNavState() }
+ // Auto-save: Back (B) / scrim commit the staged config — no Apply/Cancel row (matches the no-Save directive).
+ val doApply = {
+ val t = if (modeKey == inherit) null else current.copy(
+ mode = mode ?: TriggerEditMode.AXIS, axis = axis,
+ soft = softCmd, full = fullCmd,
+ softThresholdPct = soft, fullThresholdPct = full,
+ )
+ onApply(t, clickBind)
+ }
+
+ AlertDialog(
+ onDismissRequest = doApply,
+ containerColor = MaterialTheme.colorScheme.surface,
+ tonalElevation = 0.dp,
+ shape = MaterialTheme.shapes.large,
+ title = { Text("${side.label} behavior") },
+ text = {
+ ScNavDialogColumn(nav, onBack = doApply, modifier = Modifier.heightIn(max = 420.dp), scrollable = true) {
+ LabeledDropdown("Behavior", modeOptions, modeKey, nav = nav, navLine = 0) { modeKey = it }
+ if (mode != null) {
+ Spacer(Modifier.height(8.dp))
+ LabeledDropdown(
+ "Analog axis",
+ listOf("NONE" to "None", "GAMEPAD_L2" to "Left trigger (LT)", "GAMEPAD_R2" to "Right trigger (RT)"),
+ axis, nav = nav, navLine = 1,
+ ) { axis = it }
+ }
+ if (mode == TriggerEditMode.STAGED) {
+ Spacer(Modifier.height(8.dp))
+ AnalogSlider("Soft-pull threshold", soft, 5, 95, "%", nav = nav, navLine = 2) { soft = it }
+ Spacer(Modifier.height(8.dp))
+ AnalogSlider("Full-pull threshold", full, 5, 100, "%", nav = nav, navLine = 3) { full = it }
+ Spacer(Modifier.height(8.dp))
+ // Each stage fires its own command at its threshold — tap to bind via the standard picker.
+ val openSoft = { pickingStage = "SOFT" }
+ val openFull = { pickingStage = "FULL" }
+ ScNavItem(nav, 4, modifier = Modifier.fillMaxWidth(), onActivate = openSoft) { DetailRow("Soft-pull command", summarize(softCmd), openSoft) }
+ ScNavItem(nav, 5, modifier = Modifier.fillMaxWidth(), onActivate = openFull) { DetailRow("Full-pull command", summarize(fullCmd), openFull) }
+ }
+ // Hardware full-pull digital click (the trigger's physical detent at max pull), folded in here — was a
+ // separate button row. Independent of the analog axis / staged commands above.
+ Spacer(Modifier.height(8.dp))
+ ScSectionHeader("Full-pull")
+ val openClick = { editingClick = true }
+ ScNavItem(nav, 6, modifier = Modifier.fillMaxWidth(), onActivate = openClick) {
+ DetailRow("Full-pull binding", summarize(clickBind), openClick)
+ }
+ }
+ },
+ confirmButton = {},
+ )
+
+ // Nested command picker for whichever stage is being bound (reuses the Phase-3 picker; no activator on a stage).
+ pickingStage?.let { stage ->
+ val isSoft = stage == "SOFT"
+ BindingPickerDialog(
+ title = if (isSoft) "Soft-pull command" else "Full-pull command",
+ current = if (isSoft) softCmd else fullCmd,
+ onDismiss = { pickingStage = null },
+ onApply = { b -> if (isSoft) softCmd = b else fullCmd = b; pickingStage = null },
+ showActivator = false,
+ )
+ }
+
+ if (editingClick) {
+ BindingPickerDialog(
+ title = "${side.label} full-pull",
+ current = clickBind,
+ actionSets = actionSets,
+ onDismiss = { editingClick = false },
+ onApply = { b -> clickBind = b; editingClick = false },
+ )
+ }
+}
+
+@Composable
+private fun GyroPickerDialog(current: EditGyro, onDismiss: () -> Unit, onApply: (EditGyro?) -> Unit) {
+ val inherit = "INHERIT"
+ val modeOptions = listOf(inherit to "Inherit / keep current") + GyroEditMode.entries.map { it.name to it.uiLabel() }
+ // Reflect the gyro's actual mode so opening a configured gyro shows it, not "Inherit".
+ var modeKey by remember { mutableStateOf(current.mode.name) }
+ var sens by remember { mutableIntStateOf(current.sensitivityPct) }
+ var gate by remember { mutableStateOf(current.gate) }
+ var activation by remember { mutableStateOf(current.activation) }
+ var accel by remember { mutableStateOf(current.accel) }
+ var mixer by remember { mutableIntStateOf(current.hvMixerPct) }
+ var speedDz by remember { mutableIntStateOf(current.speedDeadzone) }
+ var precision by remember { mutableIntStateOf(current.precisionSpeed) }
+ var deflection by remember { mutableStateOf(current.deflection) }
+ var outStick by remember { mutableStateOf(current.outputStick) }
+ var powerCurve by remember { mutableIntStateOf(current.powerCurvePct) }
+ var outMax by remember { mutableIntStateOf(current.outputMaxPct) }
+ var lockEdges by remember { mutableStateOf(current.lockAtEdges) }
+ val mode = runCatching { GyroEditMode.valueOf(modeKey) }.getOrNull()
+ val nav = remember { ScNavState() }
+ // Auto-save: Back (B) / scrim commit the staged config — no Apply/Cancel row (matches the no-Save directive).
+ val doApply = {
+ if (modeKey == inherit) onApply(null)
+ else onApply(current.copy(
+ mode = mode ?: GyroEditMode.MOUSE, sensitivityPct = sens, gate = gate, activation = activation,
+ accel = accel, hvMixerPct = mixer, speedDeadzone = speedDz, precisionSpeed = precision,
+ deflection = deflection, outputStick = outStick, powerCurvePct = powerCurve,
+ outputMaxPct = outMax, lockAtEdges = lockEdges,
+ ))
+ }
+
+ AlertDialog(
+ onDismissRequest = doApply,
+ containerColor = MaterialTheme.colorScheme.surface,
+ tonalElevation = 0.dp,
+ shape = MaterialTheme.shapes.large,
+ title = { Text("Gyro behavior") },
+ text = {
+ ScNavDialogColumn(nav, onBack = doApply, modifier = Modifier.heightIn(max = 420.dp), scrollable = true) {
+ LabeledDropdown("Behavior", modeOptions, modeKey, nav = nav, navLine = 0) { modeKey = it }
+ // Sensitivity + gate + activation apply to BOTH mouse and joystick gyro.
+ if (mode == GyroEditMode.MOUSE || mode == GyroEditMode.JOYSTICK) {
+ Spacer(Modifier.height(8.dp))
+ AnalogSlider("Sensitivity", sens, 25, 400, "%", nav = nav, navLine = 1) { sens = it }
+ Spacer(Modifier.height(8.dp))
+ // "Gyro Enable/Suppress/Toggle" — what the gate button DOES.
+ LabeledDropdown(
+ "Gyro mode",
+ listOf(
+ "ENABLE" to "Hold to enable gyro", "SUPPRESS" to "Hold to suppress gyro",
+ "TOGGLE" to "Toggle gyro on/off",
+ ),
+ activation, nav = nav, navLine = 2,
+ ) { activation = it }
+ Spacer(Modifier.height(8.dp))
+ // "Choose Gyro Button(s)" — which button gates the gyro (any button, not just grips).
+ LabeledDropdown(
+ "Gyro button",
+ listOf(
+ "ALWAYS" to "Always on (no button)",
+ "EITHER_GRIP" to "Either grip", "LEFT_GRIP" to "Left grip", "RIGHT_GRIP" to "Right grip",
+ "L4" to "L4 paddle", "R4" to "R4 paddle", "L5" to "L5 paddle", "R5" to "R5 paddle",
+ "LEFT_BUMPER" to "Left bumper", "RIGHT_BUMPER" to "Right bumper",
+ "A" to "A", "B" to "B", "X" to "X", "Y" to "Y",
+ "L3" to "Left stick click", "R3" to "Right stick click",
+ "RIGHT_PAD_TOUCH" to "Right pad touch", "LEFT_PAD_TOUCH" to "Left pad touch",
+ "ANY_TOUCH" to "Any pad/stick touch", "ALL_TOUCH" to "All surfaces touched",
+ ),
+ gate, nav = nav, navLine = 3,
+ ) { gate = it }
+ }
+ // Gyro-joystick shaping — joystick only.
+ if (mode == GyroEditMode.JOYSTICK) {
+ Spacer(Modifier.height(8.dp))
+ LabeledDropdown("Style", listOf("false" to "Camera (rate → turn)", "true" to "Deflection (angle → hold)"),
+ deflection.toString(), nav = nav, navLine = 4) { deflection = it.toBoolean() }
+ Spacer(Modifier.height(8.dp))
+ LabeledDropdown("Output stick", listOf("RIGHT" to "Right stick", "LEFT" to "Left stick"),
+ outStick, nav = nav, navLine = 5) { outStick = it }
+ Spacer(Modifier.height(8.dp))
+ AnalogSlider("Power curve ×100", powerCurve, 10, 400, "", nav = nav, navLine = 6) { powerCurve = it }
+ Spacer(Modifier.height(8.dp))
+ AnalogSlider("Max output", outMax, 10, 100, "%", nav = nav, navLine = 7) { outMax = it }
+ Spacer(Modifier.height(8.dp))
+ AnalogToggle("Lock at edges", lockEdges, nav = nav, navLine = 8) { lockEdges = it }
+ }
+ // Gyro-mouse feel set (aim tuning) — mouse only.
+ if (mode == GyroEditMode.MOUSE) {
+ Spacer(Modifier.height(8.dp))
+ LabeledDropdown(
+ "Acceleration",
+ listOf("OFF" to "Off", "LINEAR" to "Linear", "RELAXED" to "Relaxed", "AGGRESSIVE" to "Aggressive"),
+ accel, nav = nav, navLine = 4,
+ ) { accel = it }
+ Spacer(Modifier.height(8.dp))
+ AnalogSlider("H/V mixer", mixer, -100, 100, "%", nav = nav, navLine = 5) { mixer = it }
+ Spacer(Modifier.height(8.dp))
+ AnalogSlider("Speed deadzone", speedDz, 0, 3000, "", nav = nav, navLine = 6) { speedDz = it }
+ Spacer(Modifier.height(8.dp))
+ AnalogSlider("Precision speed", precision, 0, 8000, "", nav = nav, navLine = 7) { precision = it }
+ }
+ }
+ },
+ confirmButton = {},
+ )
+}
+
+@Composable
+private fun HapticsDialog(current: EditHaptics, onDismiss: () -> Unit, onApply: (EditHaptics?) -> Unit) {
+ var enabled by remember { mutableStateOf(current.enabled) }
+ var left by remember { mutableStateOf(current.leftPadEnabled) }
+ var right by remember { mutableStateOf(current.rightPadEnabled) }
+ var detent by remember { mutableIntStateOf(current.detentStep) }
+ val nav = remember { ScNavState() }
+ // Auto-save: Back (B) / scrim commit the staged config — no Apply/Cancel row (matches the no-Save directive).
+ val doApply = { onApply(current.copy(enabled = enabled, leftPadEnabled = left, rightPadEnabled = right, detentStep = detent)) }
+
+ AlertDialog(
+ onDismissRequest = doApply,
+ containerColor = MaterialTheme.colorScheme.surface,
+ tonalElevation = 0.dp,
+ shape = MaterialTheme.shapes.large,
+ title = { Text("Haptics") },
+ text = {
+ ScNavDialogColumn(nav, onBack = doApply, modifier = Modifier.heightIn(max = 420.dp), scrollable = true) {
+ AnalogToggle("Haptics enabled", enabled, nav = nav, navLine = 0) { enabled = it }
+ if (enabled) {
+ AnalogToggle("Left pad", left, nav = nav, navLine = 1) { left = it }
+ AnalogToggle("Right pad", right, nav = nav, navLine = 2) { right = it }
+ Spacer(Modifier.height(8.dp))
+ AnalogSlider("Detent spacing", detent, 2000, 12000, "", nav = nav, navLine = 3) { detent = it }
+ Text(
+ "Detent spacing = pad travel between slide \"tick\" pulses; smaller = more frequent ticks.",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ }
+ }
+ },
+ confirmButton = {},
+ )
+}
+
+private fun TriggerEditMode.uiLabel(): String = when (this) {
+ TriggerEditMode.AXIS -> "Analog axis (trigger)"
+ TriggerEditMode.STAGED -> "Soft / full pull (staged)"
+}
+
+private fun GyroEditMode.uiLabel(): String = when (this) {
+ GyroEditMode.OFF -> "Off"
+ GyroEditMode.MOUSE -> "As mouse (aim)"
+ GyroEditMode.JOYSTICK -> "As joystick (camera)"
+}
+
+@Composable
+private fun SourceRow(src: ScSource, binding: EditBinding, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(src.label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
+ Text(
+ summarize(binding),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+/**
+ * Steam-style **command picker** (editor Phase 3): tabbed by command category — Keyboard / Numpad / Mouse /
+ * Gamepad — each showing a visual grid of selectable commands (a faithful clone of Steam Input's command picker,
+ * minus the Steam-only SYSTEM/CAMERA tabs and the ACTION SETS tab, which needs multi-set authoring — Phase 5).
+ * The chosen command + an [EditActivator] make up the [EditBinding]. A single binding selects exactly one command.
+ */
+@Composable
+private fun BindingPickerDialog(
+ title: String,
+ current: EditBinding,
+ onDismiss: () -> Unit,
+ onApply: (EditBinding) -> Unit,
+ /** Staged-trigger soft/full stages fire a plain output (no activator), so the activator picker is hidden there. */
+ showActivator: Boolean = true,
+ /** Available action sets (id→name) for the ACTION SETS tab. Empty hides that tab (e.g. staged-trigger pickers). */
+ actionSets: List> = emptyList(),
+ /** Optional content rendered at the top of the picker (e.g. a menu-slot label field). */
+ headerContent: (@Composable () -> Unit)? = null,
+) {
+ // Staged binding (the chosen command) — only the field matching [kind] is meaningful, mirroring EditBinding.
+ var kind by remember { mutableStateOf(current.kind) }
+ // A KEY binding is modifiers + a main key, held together (Steam's key combos). The main key is the last element;
+ // any leading entries are modifiers. The editor builds `keys` = (selected modifiers, in canonical order) + main.
+ var keyName by remember { mutableStateOf(current.keys.lastOrNull()) }
+ var modifiers by remember { mutableStateOf(current.keys.dropLast(1).toSet()) }
+ var gamepadIdx by remember { mutableStateOf(current.gamepadIdx) }
+ var dpadIndex by remember { mutableStateOf(current.dpadIndex) }
+ var mouseButton by remember { mutableStateOf(current.mouseButton) }
+ var targetSetId by remember { mutableStateOf(current.targetSetId) }
+ var layerId by remember { mutableStateOf(current.layerId) }
+ var layerOp by remember { mutableStateOf(current.layerOp) }
+ var activator by remember { mutableStateOf(current.activator) }
+ var activatorMs by remember { mutableIntStateOf(current.activatorMs) }
+ // ADVANCED is always available; ACTION_SETS only when the caller supplies sets.
+ val tabs = CmdTab.entries.filter { it != CmdTab.ACTION_SETS || actionSets.isNotEmpty() }
+ var tab by remember { mutableStateOf(initialTab(current)) }
+ // Bumpers (LB/RB) flip between command-picker tabs (the bridge dispatches L1/R1 here); the dialog root captures
+ // focus (via scCaptureFocus) so onPreviewKeyEvent fires.
+ fun cycleTab(d: Int) { tab = tabs[((tabs.indexOf(tab) + d) % tabs.size + tabs.size) % tabs.size] }
+ // Item-by-item d-pad inside the picker (command chips / key grid / Apply-Cancel). The navigable cells change with
+ // the tab, so reset the selection whenever the tab changes (ScNavState navigates only currently-registered cells).
+ val pkNav = remember { ScNavState() }
+ LaunchedEffect(tab) { pkNav.reset() }
+ val applyLine = 99 // a fixed line after any tab's content (nav only visits existing lines, so the gap is harmless)
+ // Auto-save model (no Apply button): Back (B) commits the staged binding. Kinds the picker can't author
+ // (advanced INHERIT / mouse-nudge) are preserved verbatim if untouched. Opening + backing out unchanged
+ // re-applies the same binding (idempotent), so B is always a safe "done".
+ val doApply = {
+ onApply(
+ if (kind == OutputKind.INHERIT || kind == OutputKind.MOUSE_NUDGE) current
+ else stagedBinding(kind, keyName, modifiers, gamepadIdx, dpadIndex, mouseButton, targetSetId, layerId, layerOp, activator, activatorMs),
+ )
+ }
+
+ AlertDialog(
+ modifier = Modifier.fillMaxWidth(0.95f),
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ onDismissRequest = doApply, // scrim tap / system back also commits (auto-save)
+ containerColor = MaterialTheme.colorScheme.surface,
+ tonalElevation = 0.dp,
+ shape = MaterialTheme.shapes.large,
+ title = { Text(title) },
+ text = {
+ // Bumpers flip command-picker tabs; B commits the staged binding (auto-save). All the d-pad/A/B/focus/
+ // scrollbar wiring is the shared ScNavDialogColumn.
+ ScNavDialogColumn(pkNav, onBack = doApply, onBumper = { cycleTab(it) }, scrollable = true, modifier = Modifier.heightIn(max = 500.dp)) {
+ headerContent?.invoke()
+ // A kept binding the picker can't author (advanced INHERIT, or a mouse-nudge center): show what it is.
+ val keptDesc = when (kind) {
+ OutputKind.INHERIT -> current.inheritDesc
+ OutputKind.MOUSE_NUDGE -> if (current.nudgeDx == 0 && current.nudgeDy == 0) "Center (no-op mouse nudge)" else "Mouse nudge (${current.nudgeDx},${current.nudgeDy})"
+ else -> ""
+ }
+ if (keptDesc.isNotBlank()) {
+ Text(
+ "Keeps: $keptDesc. Pick a command to replace it, or Cancel to keep it.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+ }
+ CmdTabRow(tabs, tab) { tab = it }
+ Spacer(Modifier.height(10.dp))
+ when (tab) {
+ CmdTab.KEYBOARD -> {
+ ModifierRow(modifiers, pkNav, 0) { m -> modifiers = if (m in modifiers) modifiers - m else modifiers + m }
+ Spacer(Modifier.height(6.dp))
+ KeyGrid(KB_ROWS, selected = keyName.takeIf { kind == OutputKind.KEY }, nav = pkNav, baseLine = 1) {
+ kind = OutputKind.KEY; keyName = it
+ }
+ }
+ CmdTab.NUMPAD -> {
+ ModifierRow(modifiers, pkNav, 0) { m -> modifiers = if (m in modifiers) modifiers - m else modifiers + m }
+ Spacer(Modifier.height(6.dp))
+ KeyGrid(NUMPAD_ROWS, selected = keyName.takeIf { kind == OutputKind.KEY }, nav = pkNav, baseLine = 1) {
+ kind = OutputKind.KEY; keyName = it
+ }
+ }
+ CmdTab.MOUSE -> CmdFlow {
+ MOUSE_CMDS.toList().forEachIndexed { ci, (name, label) ->
+ val pick = { kind = OutputKind.MOUSE_BUTTON; mouseButton = name }
+ ScNavItem(pkNav, 0, ci, onActivate = pick) {
+ ScChip(label, selected = kind == OutputKind.MOUSE_BUTTON && mouseButton == name, onClick = pick)
+ }
+ }
+ }
+ CmdTab.GAMEPAD -> {
+ ScSectionHeader("Buttons")
+ CmdFlow {
+ ScEditableProfile.GAMEPAD_BUTTONS.toList().forEachIndexed { ci, (idx, label) ->
+ val pick = { kind = OutputKind.GAMEPAD_BUTTON; gamepadIdx = idx }
+ ScNavItem(pkNav, 0, ci, onActivate = pick) {
+ ScChip(label.removePrefix("Pad "), selected = kind == OutputKind.GAMEPAD_BUTTON && gamepadIdx == idx, onClick = pick)
+ }
+ }
+ }
+ Spacer(Modifier.height(6.dp))
+ ScSectionHeader("D-Pad")
+ CmdFlow {
+ ScEditableProfile.DPAD_DIRECTIONS.toList().forEachIndexed { ci, (i, label) ->
+ val pick = { kind = OutputKind.GAMEPAD_DPAD; dpadIndex = i }
+ ScNavItem(pkNav, 1, ci, onActivate = pick) {
+ ScChip(label.removePrefix("Pad D-Pad "), selected = kind == OutputKind.GAMEPAD_DPAD && dpadIndex == i, onClick = pick)
+ }
+ }
+ }
+ }
+ CmdTab.ACTION_SETS -> {
+ ScSectionHeader("Switch to action set")
+ CmdFlow {
+ actionSets.forEachIndexed { ci, (id, name) ->
+ val pick = { kind = OutputKind.SWITCH_ACTION_SET; targetSetId = id }
+ ScNavItem(pkNav, 0, ci, onActivate = pick) {
+ ScChip(name.ifBlank { "Set $id" }, selected = kind == OutputKind.SWITCH_ACTION_SET && targetSetId == id, onClick = pick)
+ }
+ }
+ }
+ }
+ CmdTab.ADVANCED -> {
+ ScSectionHeader("Controller actions")
+ CmdFlow {
+ val showKb = { kind = OutputKind.SHOW_KEYBOARD }
+ val openQm = { kind = OutputKind.OPEN_QUICK_MENU }
+ ScNavItem(pkNav, 0, 0, onActivate = showKb) { ScChip("Show keyboard", selected = kind == OutputKind.SHOW_KEYBOARD, onClick = showKb) }
+ ScNavItem(pkNav, 0, 1, onActivate = openQm) { ScChip("Open QuickMenu", selected = kind == OutputKind.OPEN_QUICK_MENU, onClick = openQm) }
+ }
+ Spacer(Modifier.height(8.dp))
+ ScSectionHeader("Action layer")
+ if (actionSets.isEmpty()) {
+ Text("No layers available — mark a set as a layer in the editor first.", style = MaterialTheme.typography.bodySmall)
+ } else {
+ LabeledDropdown(
+ "Operation",
+ listOf("HOLD" to "Hold while pressed", "ADD" to "Add (toggle on)", "REMOVE" to "Remove (toggle off)"),
+ layerOp,
+ nav = pkNav,
+ navLine = 1,
+ ) { layerOp = it }
+ Spacer(Modifier.height(4.dp))
+ CmdFlow {
+ actionSets.forEachIndexed { ci, (id, name) ->
+ val pick = { kind = OutputKind.LAYER_OP; layerId = id }
+ ScNavItem(pkNav, 2, ci, onActivate = pick) {
+ ScChip(name.ifBlank { "Set $id" }, selected = kind == OutputKind.LAYER_OP && layerId == id, onClick = pick)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(Modifier.height(12.dp))
+ HorizontalDivider()
+ Spacer(Modifier.height(8.dp))
+ Text("Selected: ${summarize(stagedBinding(kind, keyName, modifiers, gamepadIdx, dpadIndex, mouseButton, targetSetId, layerId, layerOp, activator, activatorMs))}",
+ style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary)
+ if (showActivator && kind != OutputKind.NONE) {
+ Spacer(Modifier.height(8.dp))
+ // Phase 4: activator picker — pick how the press fires, plus its timing where applicable.
+ LabeledDropdown(
+ label = "Activator",
+ options = EditActivator.entries.map { it to it.uiLabel() },
+ selected = activator,
+ nav = pkNav,
+ navLine = applyLine - 1,
+ onSelected = { activator = it; activatorMs = it.defaultMs() },
+ )
+ activator.timingLabel()?.let { tl ->
+ Spacer(Modifier.height(8.dp))
+ val ms = if (activatorMs > 0) activatorMs else activator.defaultMs()
+ AnalogSlider(tl, ms, 40, 1000, " ms") { activatorMs = it }
+ }
+ }
+ // No Apply/Cancel — Back (B) commits (see doApply / ScNavDialogCapture above). Only "Clear (unbind)"
+ // remains, in the scrollable column so the d-pad can reach it.
+ val doClear = { kind = OutputKind.NONE }
+ Spacer(Modifier.height(12.dp))
+ HorizontalDivider()
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ScNavItem(pkNav, applyLine, 0, onActivate = doClear) { TextButton(onClick = doClear) { Text("Clear (unbind)") } }
+ }
+ }
+ },
+ confirmButton = {},
+ )
+}
+
+/** Build an [EditBinding] from the picker's staged fields (only the field matching [kind] is used). A KEY binding's
+ * [keys] is the selected [modifiers] (in canonical [MODIFIER_KEYS] order) followed by the main [keyName] — a held
+ * combo (e.g. Ctrl+Shift+A). */
+private fun stagedBinding(
+ kind: OutputKind, keyName: String?, modifiers: Set, gamepadIdx: Int, dpadIndex: Int, mouseButton: String,
+ targetSetId: String, layerId: String, layerOp: String, activator: EditActivator, activatorMs: Int,
+): EditBinding = EditBinding(
+ kind = kind,
+ keys = if (kind == OutputKind.KEY && keyName != null) {
+ MODIFIER_KEYS.map { it.first }.filter { it in modifiers && it != keyName } + keyName
+ } else emptyList(),
+ gamepadIdx = if (kind == OutputKind.GAMEPAD_BUTTON) gamepadIdx else -1,
+ dpadIndex = if (kind == OutputKind.GAMEPAD_DPAD) dpadIndex else -1,
+ mouseButton = if (kind == OutputKind.MOUSE_BUTTON) mouseButton else "",
+ targetSetId = if (kind == OutputKind.SWITCH_ACTION_SET) targetSetId else "",
+ layerId = if (kind == OutputKind.LAYER_OP) layerId else "",
+ layerOp = layerOp,
+ activator = activator,
+ activatorMs = activatorMs,
+)
+
+/** Modifier keys offered as multi-selectable chips above the key grid (canonical press order). */
+private val MODIFIER_KEYS: List> = listOf(
+ XKeycode.KEY_CTRL_L.name to "Ctrl",
+ XKeycode.KEY_SHIFT_L.name to "Shift",
+ XKeycode.KEY_ALT_L.name to "Alt",
+)
+
+/** A row of multi-selectable modifier chips; [selected] is the set of chosen modifier key names. */
+@Composable
+private fun ModifierRow(selected: Set, nav: ScNavState? = null, baseLine: Int = 0, onToggle: (String) -> Unit) {
+ Column {
+ ScSectionHeader("Modifiers (held with the key)")
+ CmdFlow {
+ MODIFIER_KEYS.forEachIndexed { i, mk ->
+ val name = mk.first
+ val toggle = { onToggle(name) }
+ if (nav != null) {
+ ScNavItem(nav, baseLine, i, onActivate = toggle) { ScChip(mk.second, selected = name in selected, onClick = toggle) }
+ } else {
+ ScChip(mk.second, selected = name in selected, onClick = toggle)
+ }
+ }
+ }
+ }
+}
+
+/** The command-picker tabs (Steam's SYSTEM/CAMERA are Steam-runtime-only). ACTION_SETS only shown when sets exist. */
+private enum class CmdTab(val label: String) {
+ KEYBOARD("Keyboard"), NUMPAD("Numpad"), MOUSE("Mouse"), GAMEPAD("Gamepad"), ACTION_SETS("Action Sets"), ADVANCED("Advanced")
+}
+
+private fun initialTab(b: EditBinding): CmdTab = when (b.kind) {
+ OutputKind.MOUSE_BUTTON -> CmdTab.MOUSE
+ OutputKind.GAMEPAD_BUTTON, OutputKind.GAMEPAD_DPAD -> CmdTab.GAMEPAD
+ OutputKind.SWITCH_ACTION_SET -> CmdTab.ACTION_SETS
+ OutputKind.LAYER_OP, OutputKind.SHOW_KEYBOARD, OutputKind.OPEN_QUICK_MENU -> CmdTab.ADVANCED
+ OutputKind.KEY -> if (b.keys.firstOrNull()?.let { it.startsWith("KEY_KP") || it == "KEY_NUM_LOCK" } == true) CmdTab.NUMPAD else CmdTab.KEYBOARD
+ OutputKind.NONE, OutputKind.INHERIT, OutputKind.MOUSE_NUDGE -> CmdTab.KEYBOARD
+}
+
+@Composable
+private fun CmdTabRow(tabs: List, tab: CmdTab, onSelect: (CmdTab) -> Unit) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
+ ScButtonGlyph(TritonProtocol.BTN_LBUMPER, size = 20.dp)
+ tabs.forEach { t ->
+ val sel = t == tab
+ val shape = RoundedCornerShape(16.dp)
+ Box(
+ Modifier.weight(1f)
+ .clip(shape)
+ .then(
+ if (sel) Modifier.background(MaterialTheme.colorScheme.primary, shape)
+ else Modifier.border(2.dp, MaterialTheme.colorScheme.primary, shape),
+ )
+ .clickable { onSelect(t) }
+ .padding(vertical = 8.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ t.label,
+ style = MaterialTheme.typography.labelLarge,
+ color = if (sel) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ ScButtonGlyph(TritonProtocol.BTN_RBUMPER, size = 20.dp)
+ }
+}
+
+/** A wrapping container for command chips. */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun CmdFlow(content: @Composable () -> Unit) {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) { content() }
+}
+
+/** Render keyboard-style rows of key chips; [selected] is the highlighted XKeycode name (or null). */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun KeyGrid(rows: List>, selected: String?, nav: ScNavState? = null, baseLine: Int = 0, onPick: (String) -> Unit) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ rows.forEachIndexed { r, row ->
+ FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(2.dp)) {
+ row.forEachIndexed { c, k ->
+ val pick = { onPick(k.name) }
+ if (nav != null) {
+ ScNavItem(nav, baseLine + r, c, onActivate = pick) { ScChip(keyLabel(k), selected = selected == k.name, onClick = pick) }
+ } else {
+ ScChip(keyLabel(k), selected = selected == k.name, onClick = pick)
+ }
+ }
+ }
+ }
+ }
+}
+
+// ---- Command-picker grid data ----
+
+private val KB_ROWS: List> = listOf(
+ listOf(XKeycode.KEY_ESC, XKeycode.KEY_F1, XKeycode.KEY_F2, XKeycode.KEY_F3, XKeycode.KEY_F4, XKeycode.KEY_F5, XKeycode.KEY_F6, XKeycode.KEY_F7, XKeycode.KEY_F8, XKeycode.KEY_F9, XKeycode.KEY_F10, XKeycode.KEY_F11, XKeycode.KEY_F12),
+ listOf(XKeycode.KEY_GRAVE, XKeycode.KEY_1, XKeycode.KEY_2, XKeycode.KEY_3, XKeycode.KEY_4, XKeycode.KEY_5, XKeycode.KEY_6, XKeycode.KEY_7, XKeycode.KEY_8, XKeycode.KEY_9, XKeycode.KEY_0, XKeycode.KEY_MINUS, XKeycode.KEY_EQUAL, XKeycode.KEY_BKSP),
+ listOf(XKeycode.KEY_TAB, XKeycode.KEY_Q, XKeycode.KEY_W, XKeycode.KEY_E, XKeycode.KEY_R, XKeycode.KEY_T, XKeycode.KEY_Y, XKeycode.KEY_U, XKeycode.KEY_I, XKeycode.KEY_O, XKeycode.KEY_P, XKeycode.KEY_BRACKET_LEFT, XKeycode.KEY_BRACKET_RIGHT, XKeycode.KEY_BACKSLASH),
+ listOf(XKeycode.KEY_CAPS_LOCK, XKeycode.KEY_A, XKeycode.KEY_S, XKeycode.KEY_D, XKeycode.KEY_F, XKeycode.KEY_G, XKeycode.KEY_H, XKeycode.KEY_J, XKeycode.KEY_K, XKeycode.KEY_L, XKeycode.KEY_SEMICOLON, XKeycode.KEY_APOSTROPHE, XKeycode.KEY_ENTER),
+ listOf(XKeycode.KEY_SHIFT_L, XKeycode.KEY_Z, XKeycode.KEY_X, XKeycode.KEY_C, XKeycode.KEY_V, XKeycode.KEY_B, XKeycode.KEY_N, XKeycode.KEY_M, XKeycode.KEY_COMMA, XKeycode.KEY_PERIOD, XKeycode.KEY_SLASH, XKeycode.KEY_SHIFT_R),
+ listOf(XKeycode.KEY_CTRL_L, XKeycode.KEY_ALT_L, XKeycode.KEY_SPACE, XKeycode.KEY_ALT_R, XKeycode.KEY_CTRL_R),
+ listOf(XKeycode.KEY_INSERT, XKeycode.KEY_HOME, XKeycode.KEY_PRIOR, XKeycode.KEY_DEL, XKeycode.KEY_END, XKeycode.KEY_NEXT, XKeycode.KEY_UP, XKeycode.KEY_LEFT, XKeycode.KEY_DOWN, XKeycode.KEY_RIGHT, XKeycode.KEY_PRTSCN),
+)
+
+private val NUMPAD_ROWS: List> = listOf(
+ listOf(XKeycode.KEY_NUM_LOCK, XKeycode.KEY_KP_DIVIDE, XKeycode.KEY_KP_MULTIPLY, XKeycode.KEY_KP_SUBTRACT),
+ listOf(XKeycode.KEY_KP_7, XKeycode.KEY_KP_8, XKeycode.KEY_KP_9, XKeycode.KEY_KP_ADD),
+ listOf(XKeycode.KEY_KP_4, XKeycode.KEY_KP_5, XKeycode.KEY_KP_6),
+ listOf(XKeycode.KEY_KP_1, XKeycode.KEY_KP_2, XKeycode.KEY_KP_3, XKeycode.KEY_KP_ENTER),
+ listOf(XKeycode.KEY_KP_0, XKeycode.KEY_KP_DEL),
+)
+
+private val MOUSE_CMDS: List> = listOf(
+ Pointer.Button.BUTTON_LEFT.name to "Left Click",
+ Pointer.Button.BUTTON_RIGHT.name to "Right Click",
+ Pointer.Button.BUTTON_MIDDLE.name to "Middle Click",
+ Pointer.Button.BUTTON_SCROLL_UP.name to "Scroll Up",
+ Pointer.Button.BUTTON_SCROLL_DOWN.name to "Scroll Down",
+)
+
+/** Compact display label for a key chip (symbols/arrows instead of the raw enum name). */
+private fun keyLabel(k: XKeycode): String = when (k) {
+ XKeycode.KEY_GRAVE -> "`"; XKeycode.KEY_MINUS -> "-"; XKeycode.KEY_EQUAL -> "="; XKeycode.KEY_BKSP -> "⌫"
+ XKeycode.KEY_TAB -> "Tab"; XKeycode.KEY_BRACKET_LEFT -> "["; XKeycode.KEY_BRACKET_RIGHT -> "]"; XKeycode.KEY_BACKSLASH -> "\\"
+ XKeycode.KEY_CAPS_LOCK -> "Caps"; XKeycode.KEY_SEMICOLON -> ";"; XKeycode.KEY_APOSTROPHE -> "'"; XKeycode.KEY_ENTER -> "⏎ Enter"
+ XKeycode.KEY_SHIFT_L -> "⇧ Shift L"; XKeycode.KEY_SHIFT_R -> "⇧ Shift R"; XKeycode.KEY_COMMA -> ","; XKeycode.KEY_PERIOD -> "."; XKeycode.KEY_SLASH -> "/"
+ XKeycode.KEY_CTRL_L -> "Ctrl L"; XKeycode.KEY_CTRL_R -> "Ctrl R"; XKeycode.KEY_ALT_L -> "Alt L"; XKeycode.KEY_ALT_R -> "Alt R"; XKeycode.KEY_SPACE -> "Space"
+ XKeycode.KEY_INSERT -> "Ins"; XKeycode.KEY_HOME -> "Home"; XKeycode.KEY_PRIOR -> "PgUp"; XKeycode.KEY_DEL -> "Del"; XKeycode.KEY_END -> "End"; XKeycode.KEY_NEXT -> "PgDn"
+ XKeycode.KEY_UP -> "↑"; XKeycode.KEY_LEFT -> "←"; XKeycode.KEY_DOWN -> "↓"; XKeycode.KEY_RIGHT -> "→"; XKeycode.KEY_PRTSCN -> "PrtSc"
+ XKeycode.KEY_NUM_LOCK -> "Num"; XKeycode.KEY_KP_DIVIDE -> "/"; XKeycode.KEY_KP_MULTIPLY -> "*"; XKeycode.KEY_KP_SUBTRACT -> "-"; XKeycode.KEY_KP_ADD -> "+"
+ XKeycode.KEY_KP_ENTER -> "⏎"; XKeycode.KEY_KP_DEL -> "."
+ XKeycode.KEY_KP_0 -> "0"; XKeycode.KEY_KP_1 -> "1"; XKeycode.KEY_KP_2 -> "2"; XKeycode.KEY_KP_3 -> "3"; XKeycode.KEY_KP_4 -> "4"
+ XKeycode.KEY_KP_5 -> "5"; XKeycode.KEY_KP_6 -> "6"; XKeycode.KEY_KP_7 -> "7"; XKeycode.KEY_KP_8 -> "8"; XKeycode.KEY_KP_9 -> "9"
+ else -> k.name.removePrefix("KEY_")
+}
+
+/** A label + a dropdown selector for a list of (value, label) options. */
+@Composable
+private fun LabeledDropdown(
+ label: String,
+ options: List>,
+ selected: T,
+ /** Optional d-pad nav: the trigger becomes a nav cell that CYCLES to the next option on A (a DropdownMenu popup is
+ * a separate window the controller-nav bridge can't reach, so cycling in-place is the d-pad-friendly path). Touch
+ * still opens the full menu. */
+ nav: ScNavState? = null,
+ navLine: Int = 0,
+ onSelected: (T) -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ var choosing by remember { mutableStateOf(false) }
+ val selectedLabel = options.firstOrNull { it.first == selected }?.second ?: "—"
+ // With nav: tapping/A opens a controller-navigable choice modal (shows all options). Without: a Material popup.
+ val open = { if (nav != null) choosing = true else expanded = true }
+ Box {
+ // Same full-width row shape as the bindings-menu rows (label + value ▾), so the only outline is the nav
+ // selection ring — no inner button outline fighting it, and it aligns/pads identically to [DetailRow].
+ val row: @Composable () -> Unit = {
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable(onClick = open).padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
+ Text("$selectedLabel ▾", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ if (nav != null) {
+ ScNavItem(nav, navLine, modifier = Modifier.fillMaxWidth(), onActivate = open) { row() }
+ } else {
+ row()
+ }
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ modifier = Modifier.heightIn(max = 360.dp).background(MaterialTheme.colorScheme.surface),
+ ) {
+ options.forEach { (value, lbl) ->
+ DropdownMenuItem(
+ text = { Text(lbl, fontWeight = if (value == selected) FontWeight.Bold else FontWeight.Normal) },
+ onClick = { onSelected(value); expanded = false },
+ )
+ }
+ }
+ }
+ if (choosing) {
+ ScNavChoiceDialog(
+ title = label,
+ options = options,
+ selected = selected,
+ onPick = onSelected,
+ onDismiss = { choosing = false },
+ )
+ }
+}
+
+private fun EditActivator.uiLabel(): String = when (this) {
+ EditActivator.REGULAR -> "Regular (press)"
+ EditActivator.DOUBLE_PRESS -> "Double press"
+ EditActivator.LONG_PRESS -> "Long press"
+ EditActivator.TURBO -> "Turbo (rapid fire)"
+ EditActivator.RELEASE -> "On release"
+}
+
+/** The ms setting label for an activator that has one, or null for those that don't (Regular / Release). */
+private fun EditActivator.timingLabel(): String? = when (this) {
+ EditActivator.DOUBLE_PRESS -> "Double-press window"
+ EditActivator.LONG_PRESS -> "Hold time"
+ EditActivator.TURBO -> "Repeat interval"
+ else -> null
+}
+
+/** Default ms for an activator's timing (used when the binding's activatorMs is 0). */
+private fun EditActivator.defaultMs(): Int = when (this) {
+ EditActivator.DOUBLE_PRESS -> 300
+ EditActivator.LONG_PRESS -> 500
+ EditActivator.TURBO -> 80
+ else -> 0
+}
+
+/** Compact label for a key in a combo summary: friendly modifier names, else the [keyLabel]-style short form. */
+private fun comboKeyLabel(name: String): String = when (name) {
+ XKeycode.KEY_CTRL_L.name, XKeycode.KEY_CTRL_R.name -> "Ctrl"
+ XKeycode.KEY_SHIFT_L.name, XKeycode.KEY_SHIFT_R.name -> "Shift"
+ XKeycode.KEY_ALT_L.name, XKeycode.KEY_ALT_R.name -> "Alt"
+ else -> runCatching { keyLabel(XKeycode.valueOf(name)) }.getOrDefault(name.removePrefix("KEY_"))
+}
+
+private fun summarize(b: EditBinding): String {
+ val out = when (b.kind) {
+ OutputKind.NONE -> "Unbound"
+ OutputKind.INHERIT -> b.inheritDesc.ifBlank { "Advanced (kept)" }
+ OutputKind.KEY -> b.keys.joinToString("+") { comboKeyLabel(it) }.ifBlank { "Key" }
+ OutputKind.GAMEPAD_BUTTON ->
+ ScEditableProfile.GAMEPAD_BUTTONS.firstOrNull { it.first == b.gamepadIdx }?.second ?: "Pad button"
+ OutputKind.GAMEPAD_DPAD ->
+ ScEditableProfile.DPAD_DIRECTIONS.firstOrNull { it.first == b.dpadIndex }?.second ?: "Pad d-pad"
+ OutputKind.MOUSE_BUTTON -> b.mouseButton.removePrefix("BUTTON_").ifBlank { "Mouse" }
+ OutputKind.SWITCH_ACTION_SET -> "→ set ${b.targetSetId.ifBlank { "?" }}"
+ OutputKind.MOUSE_NUDGE -> if (b.nudgeDx == 0 && b.nudgeDy == 0) "Center (no-op)" else "Nudge (${b.nudgeDx},${b.nudgeDy})"
+ OutputKind.LAYER_OP -> "${b.layerOp.lowercase().replaceFirstChar { it.uppercase() }} layer ${b.layerId.ifBlank { "?" }}"
+ OutputKind.SHOW_KEYBOARD -> "Show keyboard"
+ OutputKind.OPEN_QUICK_MENU -> "Open QuickMenu"
+ }
+ val act = when (b.activator) {
+ EditActivator.REGULAR -> ""
+ EditActivator.DOUBLE_PRESS -> " · double"
+ EditActivator.LONG_PRESS -> " · long"
+ EditActivator.TURBO -> " · turbo"
+ EditActivator.RELEASE -> " · release"
+ }
+ return out + act
+}
+
+// ---- Analog sources (Phase 2: per-surface behavior + settings) ----
+
+/** The four analog surfaces the editor authors a behavior for, with get/set accessors on [ScEditableProfile]. */
+private enum class AnalogSurface(
+ val label: String,
+ val isStick: Boolean,
+ val location: ScMenuLocation,
+ /** The stick/pad *click* button folded into this surface's editor (its click bind lives here, not in the button list). */
+ val clickSource: ScSource,
+) {
+ LEFT_STICK("Left Stick", true, ScMenuLocation.LEFT_STICK, ScSource.LEFT_STICK_CLICK),
+ RIGHT_STICK("Right Stick", true, ScMenuLocation.RIGHT_STICK, ScSource.RIGHT_STICK_CLICK),
+ LEFT_PAD("Left Pad", false, ScMenuLocation.LEFT_PAD, ScSource.LEFT_PAD_CLICK),
+ RIGHT_PAD("Right Pad", false, ScMenuLocation.RIGHT_PAD, ScSource.RIGHT_PAD_CLICK);
+
+ fun get(p: ScEditableProfile): EditAnalog? = when (this) {
+ LEFT_STICK -> p.leftStick; RIGHT_STICK -> p.rightStick; LEFT_PAD -> p.leftPad; RIGHT_PAD -> p.rightPad
+ }
+
+ fun set(p: ScEditableProfile, a: EditAnalog?): ScEditableProfile = when (this) {
+ LEFT_STICK -> p.copy(leftStick = a); RIGHT_STICK -> p.copy(rightStick = a)
+ LEFT_PAD -> p.copy(leftPad = a); RIGHT_PAD -> p.copy(rightPad = a)
+ }
+
+ /** Behaviors offered for this surface kind. Sticks: joystick/mouse/flick + radial/touch menu + d-pad.
+ * Pads: mouse/scroll + radial/touch menu + button-pad + d-pad. (Button-pad is pad-only.) */
+ fun modes(): List = if (isStick)
+ listOf(AnalogMode.JOYSTICK, AnalogMode.MOUSE, AnalogMode.FLICK_STICK, AnalogMode.RADIAL, AnalogMode.TOUCH_MENU, AnalogMode.DPAD, AnalogMode.NONE)
+ else listOf(AnalogMode.MOUSE, AnalogMode.SCROLL_WHEEL, AnalogMode.RADIAL, AnalogMode.TOUCH_MENU, AnalogMode.BUTTON_PAD, AnalogMode.DPAD, AnalogMode.NONE)
+
+ companion object { val ALL = entries }
+}
+
+@Composable
+private fun AnalogSurfaceRow(surface: AnalogSurface, current: EditAnalog?, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(surface.label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
+ Text(summarizeAnalog(current), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+}
+
+private fun summarizeAnalog(a: EditAnalog?): String = when (a?.mode) {
+ null -> "Inherit (default)"
+ AnalogMode.NONE -> "Off"
+ AnalogMode.MOUSE -> "Mouse"
+ AnalogMode.JOYSTICK -> "Joystick (${a.outputStick.lowercase()})"
+ AnalogMode.FLICK_STICK -> "Flick stick"
+ AnalogMode.SCROLL_WHEEL -> "Scroll wheel"
+ AnalogMode.DPAD -> "D-Pad"
+ AnalogMode.RADIAL -> "Radial menu (${a.slots.size})"
+ AnalogMode.TOUCH_MENU -> "Touch menu (${a.menuCols}×${a.menuRows})"
+ AnalogMode.BUTTON_PAD -> "Button pad (${a.menuCols}×${a.menuRows})"
+}
+
+@Composable
+private fun AnalogPickerDialog(
+ surface: AnalogSurface,
+ current: EditAnalog?,
+ clickBinding: EditBinding,
+ actionSets: List>,
+ onDismiss: () -> Unit,
+ onApply: (EditAnalog?, EditBinding) -> Unit,
+) {
+ val inherit = "INHERIT"
+ val modeOptions = listOf(inherit to "Inherit / keep current") + surface.modes().map { it.name to it.uiLabel() }
+ var modeKey by remember { mutableStateOf(if (current == null) inherit else current.mode.name) }
+ var sens by remember { mutableIntStateOf(current?.sensitivityPct ?: 100) }
+ var dead by remember { mutableIntStateOf(current?.deadzonePct ?: 12) }
+ var invertY by remember { mutableStateOf(current?.invertY ?: false) }
+ var curve by remember { mutableStateOf(current?.curve ?: EditCurve.LINEAR) }
+ var outStick by remember { mutableStateOf(current?.outputStick ?: "RIGHT") }
+ var scrollStep by remember { mutableIntStateOf(current?.scrollStep ?: 6000) }
+ // Pad MOUSE touch-feel (per-pad; folded in from the old global "Touchpad & menus" tuning menu).
+ var smoothingPct by remember { mutableIntStateOf(current?.smoothingPct ?: ScTuningStore.DEFAULT_SMOOTHING) }
+ var jitter by remember { mutableIntStateOf(current?.jitterFloor ?: 24) }
+
+ // Menu / d-pad authoring state (used when the chosen mode is RADIAL / TOUCH_MENU / BUTTON_PAD / DPAD).
+ var slots by remember { mutableStateOf(current?.slots ?: emptyList()) }
+ var menuCols by remember { mutableIntStateOf(current?.menuCols?.takeIf { it > 0 } ?: 2) }
+ var menuRows by remember { mutableIntStateOf(current?.menuRows?.takeIf { it > 0 } ?: 2) }
+ var menuHold by remember { mutableStateOf(current?.menuHold ?: surface.isStick) }
+ var menuOnClick by remember { mutableStateOf(current?.menuOnClick ?: false) }
+ var menuDirectional by remember { mutableStateOf(current?.menuDirectional ?: false) }
+ var menuCenter by remember { mutableStateOf(current?.menuCenter) }
+ var dUp by remember { mutableStateOf(current?.up ?: EditBinding()) }
+ var dDown by remember { mutableStateOf(current?.down ?: EditBinding()) }
+ var dLeft by remember { mutableStateOf(current?.left ?: EditBinding()) }
+ var dRight by remember { mutableStateOf(current?.right ?: EditBinding()) }
+ // Nested binding-picker target: a slot index, the radial center (-1), or a d-pad direction key.
+ var editingSlot by remember { mutableStateOf(null) }
+ var editingDir by remember { mutableStateOf(null) }
+ // The stick/pad click binding, folded into this surface's editor (was a separate button row).
+ var clickBind by remember { mutableStateOf(clickBinding) }
+ var editingClick by remember { mutableStateOf(false) }
+
+ val mode = runCatching { AnalogMode.valueOf(modeKey) }.getOrNull()
+ val isMenu = mode == AnalogMode.RADIAL || mode == AnalogMode.TOUCH_MENU || mode == AnalogMode.BUTTON_PAD
+ val isGrid = mode == AnalogMode.TOUCH_MENU || mode == AnalogMode.BUTTON_PAD
+ val showSens = mode == AnalogMode.MOUSE || mode == AnalogMode.FLICK_STICK
+ // Pad-mouse feel = smoothing + jitter floor (below); a pad has no analog "deadzone %". Sticks self-center, so
+ // stick-mouse still uses the % deadzone. So show the % deadzone for everything EXCEPT a pad in MOUSE mode.
+ val showTouchFeel = mode == AnalogMode.MOUSE && !surface.isStick
+ val showDead = (mode == AnalogMode.JOYSTICK || (mode == AnalogMode.MOUSE && surface.isStick) || mode == AnalogMode.FLICK_STICK ||
+ ((mode == AnalogMode.RADIAL || mode == AnalogMode.TOUCH_MENU) && surface.isStick) || mode == AnalogMode.DPAD)
+ val showInvert = mode == AnalogMode.MOUSE || mode == AnalogMode.JOYSTICK
+ val showCurve = surface.isStick && (mode == AnalogMode.JOYSTICK || mode == AnalogMode.MOUSE)
+
+ fun build(): EditAnalog = EditAnalog(
+ mode = mode ?: AnalogMode.NONE, sensitivityPct = sens, deadzonePct = dead,
+ smoothingPct = smoothingPct, jitterFloor = jitter,
+ invertY = invertY, curve = curve, outputStick = outStick, scrollStep = scrollStep,
+ up = dUp, down = dDown, left = dLeft, right = dRight,
+ slots = slots, menuCols = menuCols, menuRows = menuRows, menuOnClick = menuOnClick,
+ menuHold = menuHold, menuCenter = menuCenter, menuDirectional = menuDirectional,
+ )
+ val nav = remember { ScNavState() }
+ // Auto-save: Back (B) / scrim commit the staged config — no Apply/Cancel row (matches the no-Save directive).
+ val doApply = { if (modeKey == inherit) onApply(null, clickBind) else onApply(build(), clickBind) }
+
+ AlertDialog(
+ modifier = Modifier.fillMaxWidth(0.95f),
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ onDismissRequest = doApply,
+ containerColor = MaterialTheme.colorScheme.surface,
+ tonalElevation = 0.dp,
+ shape = MaterialTheme.shapes.large,
+ title = { Text("${surface.label} behavior") },
+ text = {
+ ScNavDialogColumn(nav, onBack = doApply, modifier = Modifier.heightIn(max = 520.dp), scrollable = true) {
+ LabeledDropdown("Behavior", modeOptions, modeKey, nav = nav, navLine = 0) { modeKey = it }
+ if (showSens) { Spacer(Modifier.height(8.dp)); AnalogSlider("Sensitivity", sens, 25, 400, "%", nav = nav, navLine = 1) { sens = it } }
+ if (showDead) { Spacer(Modifier.height(8.dp)); AnalogSlider("Deadzone", dead, 0, 50, "%", nav = nav, navLine = 2) { dead = it } }
+ if (showTouchFeel) {
+ Spacer(Modifier.height(8.dp)); AnalogSlider("Smoothing", smoothingPct, 0, 100, "%", nav = nav, navLine = 2) { smoothingPct = it }
+ Spacer(Modifier.height(8.dp)); AnalogSlider("Jitter floor (rest deadzone)", jitter, 0, 100, "", nav = nav, navLine = 3) { jitter = it }
+ }
+ if (mode == AnalogMode.SCROLL_WHEEL) { Spacer(Modifier.height(8.dp)); AnalogSlider("Scroll step", scrollStep, 1000, 12000, "", nav = nav, navLine = 3) { scrollStep = it } }
+ if (mode == AnalogMode.JOYSTICK) { Spacer(Modifier.height(8.dp)); LabeledDropdown("Output stick", listOf("LEFT" to "Left stick", "RIGHT" to "Right stick"), outStick, nav = nav, navLine = 4) { outStick = it } }
+ if (showCurve) { Spacer(Modifier.height(8.dp)); LabeledDropdown("Response curve", EditCurve.entries.map { it to it.name.lowercase().replaceFirstChar { c -> c.uppercase() } }, curve, nav = nav, navLine = 5) { curve = it } }
+ if (showInvert) { Spacer(Modifier.height(8.dp)); AnalogToggle("Invert Y", invertY, nav = nav, navLine = 6) { invertY = it } }
+
+ // ── D-Pad: four direction binds ──
+ if (mode == AnalogMode.DPAD) {
+ Spacer(Modifier.height(8.dp))
+ val dirs = listOf("UP" to dUp, "DOWN" to dDown, "LEFT" to dLeft, "RIGHT" to dRight)
+ dirs.forEachIndexed { i, (dir, b) ->
+ val open = { editingDir = dir }
+ ScNavItem(nav, 7 + i, modifier = Modifier.fillMaxWidth(), onActivate = open) {
+ DetailRow(dir.lowercase().replaceFirstChar { it.uppercase() }, summarize(b), open)
+ }
+ }
+ }
+
+ // ── Menus: grid dims + commit/onClick + slot list (+ radial center/directional) ──
+ if (isMenu) {
+ Spacer(Modifier.height(8.dp))
+ if (isGrid) {
+ AnalogSlider("Columns", menuCols, 1, 6, "", nav = nav, navLine = 11) { menuCols = it }
+ AnalogSlider("Rows", menuRows, 1, 6, "", nav = nav, navLine = 12) { menuRows = it }
+ }
+ if (mode != AnalogMode.BUTTON_PAD) {
+ AnalogToggle("Hold (else commit on release)", menuHold, nav = nav, navLine = 13) { menuHold = it }
+ }
+ if (!surface.isStick) AnalogToggle("Commit on pad click", menuOnClick, nav = nav, navLine = 14) { menuOnClick = it }
+ if (mode == AnalogMode.RADIAL) {
+ AnalogToggle("Directional (8-way movement)", menuDirectional, nav = nav, navLine = 15) { menuDirectional = it }
+ val openCenter = { editingSlot = -1 }
+ ScNavItem(nav, 16, modifier = Modifier.fillMaxWidth(), onActivate = openCenter) {
+ DetailRow("Center button", menuCenter?.let { summarize(it.binding) } ?: "None", openCenter)
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ Text(if (mode == AnalogMode.BUTTON_PAD) "Cells (row-major)" else "Slots", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
+ slots.forEachIndexed { i, s ->
+ Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text("${i + 1}.", modifier = Modifier.width(24.dp), style = MaterialTheme.typography.bodyMedium)
+ val editSlot = { editingSlot = i }
+ val removeSlot = { slots = slots.toMutableList().also { it.removeAt(i) } }
+ ScNavItem(nav, 20 + i, col = 0, modifier = Modifier.weight(1f), onActivate = editSlot) {
+ // Inset so the nav ring clears the text (was colliding), and the row reads as a chip.
+ Box(Modifier.fillMaxWidth().clickable { editSlot() }.padding(horizontal = 12.dp, vertical = 10.dp)) {
+ Text(
+ (s.label.ifBlank { "(no label)" }) + " — " + summarize(s.binding),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+ ScNavItem(nav, 20 + i, col = 1, onActivate = removeSlot) { TextButton(onClick = removeSlot) { Text("✕") } }
+ }
+ }
+ val addSlot = { slots = slots + EditMenuSlot() }
+ ScNavItem(nav, 999, modifier = Modifier.fillMaxWidth(), onActivate = addSlot) {
+ TextButton(onClick = addSlot) { Text("+ Add ${if (mode == AnalogMode.BUTTON_PAD) "cell" else "slot"}") }
+ }
+ }
+
+ // Folded-in click bind for this stick/pad (was a separate button row; kept here so the whole surface
+ // is configured in one place).
+ Spacer(Modifier.height(8.dp))
+ Text(
+ if (surface.isStick) "Stick click" else "Pad click",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
+ letterSpacing = MaterialTheme.typography.labelMedium.letterSpacing * 1.5f,
+ )
+ val openClick = { editingClick = true }
+ ScNavItem(nav, 1000, modifier = Modifier.fillMaxWidth(), onActivate = openClick) {
+ DetailRow("Click", summarize(clickBind), openClick)
+ }
+ }
+ },
+ confirmButton = {},
+ )
+
+ // Nested command picker for a slot / the radial center / a d-pad direction.
+ editingSlot?.let { idx ->
+ val cur = if (idx == -1) (menuCenter?.binding ?: EditBinding()) else slots.getOrNull(idx)?.binding ?: EditBinding()
+ val curLabel = if (idx == -1) (menuCenter?.label ?: "") else slots.getOrNull(idx)?.label ?: ""
+ SlotPickerDialog(
+ title = if (idx == -1) "Center button" else "Slot ${idx + 1}",
+ currentBinding = cur, currentLabel = curLabel, showLabel = mode != AnalogMode.BUTTON_PAD,
+ onDismiss = { editingSlot = null },
+ onApply = { b, lbl ->
+ if (idx == -1) menuCenter = EditMenuSlot(lbl, b)
+ else slots = slots.toMutableList().also { it[idx] = EditMenuSlot(lbl, b) }
+ editingSlot = null
+ },
+ )
+ }
+ editingDir?.let { dir ->
+ BindingPickerDialog(
+ title = "D-Pad $dir",
+ current = when (dir) { "UP" -> dUp; "DOWN" -> dDown; "LEFT" -> dLeft; else -> dRight },
+ onDismiss = { editingDir = null },
+ onApply = { b -> when (dir) { "UP" -> dUp = b; "DOWN" -> dDown = b; "LEFT" -> dLeft = b; else -> dRight = b }; editingDir = null },
+ showActivator = false,
+ )
+ }
+ if (editingClick) {
+ BindingPickerDialog(
+ title = "${surface.label} click",
+ current = clickBind,
+ actionSets = actionSets,
+ onDismiss = { editingClick = false },
+ onApply = { b -> clickBind = b; editingClick = false },
+ )
+ }
+}
+
+/** A slot editor: the shared command picker plus an optional slot-label field (menus show a HUD label; button-pad
+ * cells don't). Wraps [BindingPickerDialog] and threads the label through. */
+@Composable
+private fun SlotPickerDialog(
+ title: String,
+ currentBinding: EditBinding,
+ currentLabel: String,
+ showLabel: Boolean,
+ onDismiss: () -> Unit,
+ onApply: (EditBinding, String) -> Unit,
+) {
+ var label by remember { mutableStateOf(currentLabel) }
+ // The label field lives above the picker; the picker's Apply carries the label out with the chosen binding.
+ BindingPickerDialog(
+ title = title,
+ current = currentBinding,
+ onDismiss = onDismiss,
+ onApply = { b -> onApply(b, label) },
+ headerContent = if (showLabel) {
+ {
+ ScTextEditField(
+ label = "Slot label (HUD)",
+ value = label,
+ onValueChange = { label = it },
+ modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
+ )
+ }
+ } else null,
+ )
+}
+
+@Composable
+private fun AnalogSlider(
+ label: String,
+ value: Int,
+ min: Int,
+ max: Int,
+ suffix: String,
+ nav: ScNavState? = null,
+ navLine: Int = 0,
+ onChange: (Int) -> Unit,
+) {
+ // With nav: d-pad LEFT/RIGHT nudge the value 1 point at a time (hold to auto-repeat for larger ranges).
+ val nudge: (Int) -> Unit = { d -> onChange((value + d).coerceIn(min, max)) }
+ val body: @Composable () -> Unit = {
+ // Inset the content so the nav-selection ring hugs the row edge without overlapping the label/slider.
+ Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) {
+ Text("$label: $value$suffix" + if (nav != null) " ◀ ▶" else "", style = MaterialTheme.typography.labelMedium)
+ Slider(value = value.toFloat(), onValueChange = { onChange(it.toInt()) }, valueRange = min.toFloat()..max.toFloat())
+ }
+ }
+ if (nav != null) {
+ ScNavItem(nav, navLine, modifier = Modifier.fillMaxWidth(), onHorizontal = nudge, onActivate = {}) { body() }
+ } else {
+ body()
+ }
+}
+
+@Composable
+private fun AnalogToggle(label: String, value: Boolean, nav: ScNavState? = null, navLine: Int = 0, onChange: (Boolean) -> Unit) {
+ val toggle = { onChange(!value) }
+ val body: @Composable () -> Unit = {
+ Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
+ // Themed pill (matches the chips) instead of a Material button whose own outline fought the nav ring.
+ ScChip(if (value) "On" else "Off", selected = value, onClick = toggle)
+ }
+ }
+ if (nav != null) {
+ ScNavItem(nav, navLine, modifier = Modifier.fillMaxWidth(), onActivate = toggle) { body() }
+ } else {
+ body()
+ }
+}
+
+private fun AnalogMode.uiLabel(): String = when (this) {
+ AnalogMode.NONE -> "Off"
+ AnalogMode.MOUSE -> "Mouse"
+ AnalogMode.JOYSTICK -> "Joystick"
+ AnalogMode.FLICK_STICK -> "Flick stick"
+ AnalogMode.SCROLL_WHEEL -> "Scroll wheel"
+ AnalogMode.DPAD -> "D-Pad"
+ AnalogMode.RADIAL -> "Radial menu"
+ AnalogMode.TOUCH_MENU -> "Touch menu"
+ AnalogMode.BUTTON_PAD -> "Button pad"
+}
diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt
index 4b7fb79617..c6a7b66a3a 100644
--- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt
+++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt
@@ -459,6 +459,9 @@ class MainViewModel @Inject constructor(
fun setDiagnostics(value: Boolean) {
_state.update { it.copy(diagnostics = value) }
+ // Persist so setupXEnvironment can read it directly (see PrefManager.wrapperDiagnostics) rather than the
+ // XServerScreen composable threading it as a parameter, which tripped an ART VerifyError on that huge method.
+ PrefManager.wrapperDiagnostics = value
}
fun launchApp(context: Context, appId: String) {
diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
index bd880971d4..0e59488678 100644
--- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
@@ -59,6 +59,8 @@ import androidx.compose.runtime.setValue
import app.gamenative.MainActivity
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.type
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.ui.Alignment
import androidx.compose.ui.input.pointer.PointerIcon
@@ -107,6 +109,10 @@ import app.gamenative.service.epic.EpicService
import app.gamenative.service.gog.GOGService
import app.gamenative.ui.component.QuickMenu
import app.gamenative.ui.component.QuickMenuAction
+import app.gamenative.ui.component.dialog.ScConfigManagerDialog
+import app.gamenative.ui.component.dialog.ScOverlayEditorDialog
+import app.gamenative.ui.component.dialog.ScOverlayTarget
+import app.gamenative.ui.component.dialog.SteamControllerBindingEditorDialog
import app.gamenative.ui.component.parseBooleanExtra
import app.gamenative.ui.component.parsePositiveFpsLimit
import app.gamenative.ui.data.PerformanceHudConfig
@@ -343,7 +349,9 @@ fun XServerScreen(
appId: String,
bootToContainer: Boolean,
testGraphics: Boolean = false,
- diagnostics: Boolean = false,
+ // NOTE: `diagnostics` is intentionally NOT a parameter here — read it via PrefManager.wrapperDiagnostics at the
+ // setupXEnvironment call below. Adding a parameter to this (very large) composable pushed the method past ART's
+ // bytecode-verifier limit → VerifyError / crash on every game launch. Keep new inputs out of this signature.
isOffline: Boolean = false,
registerBackAction: ( ( ) -> Unit ) -> Unit,
navigateBack: () -> Unit,
@@ -474,6 +482,7 @@ fun XServerScreen(
var win32AppWorkarounds: Win32AppWorkarounds? by remember { mutableStateOf(null) }
var physicalControllerHandler: PhysicalControllerHandler? by remember { mutableStateOf(null) }
+ var tritonMapper: app.gamenative.steamcontroller.TritonMapper? by remember { mutableStateOf(null) }
var exitWatchJob: Job? by remember { mutableStateOf(null) }
val keyboardEscMenuHandler = remember(scope) { KeyboardEscMenuHandler(scope) }
@@ -481,6 +490,8 @@ fun XServerScreen(
onDispose {
physicalControllerHandler?.cleanup()
physicalControllerHandler = null
+ tritonMapper?.stop()
+ tritonMapper = null
exitWatchJob?.cancel()
exitWatchJob = null
keyboardEscMenuHandler.cancel()
@@ -497,6 +508,16 @@ fun XServerScreen(
var showElementEditor by remember { mutableStateOf(false) }
var elementToEdit by remember { mutableStateOf(null) }
var showPhysicalControllerDialog by remember { mutableStateOf(false) }
+ // Steam Controller in-game live editors (shown from the QuickMenu CONTROLLER tab when an SC is connected).
+ var showScRoot by remember { mutableStateOf(false) }
+ var showScBindings by remember { mutableStateOf(false) }
+ var showScLayout by remember { mutableStateOf(false) }
+ var showScKeyboard by remember { mutableStateOf(false) }
+ var showScConfigs by remember { mutableStateOf(false) }
+ // When a sub-editor is opened from the SC root hub, closing it (B / Back) should return to the hub — one level
+ // at a time — instead of resuming the game. This flag records "came from the hub" so the sub-editor's dismiss
+ // reopens the hub rather than calling scEditorDismiss (which would drop straight back to the game).
+ var scReturnToRoot by remember { mutableStateOf(false) }
var showPlayingBlockedDialog by rememberSaveable { mutableStateOf(false) }
var playingBlockedRemoteName by rememberSaveable { mutableStateOf(null) }
var showTouchGestureDialog by remember { mutableStateOf(false) }
@@ -786,6 +807,10 @@ fun XServerScreen(
Timber.d("Skipping overlay suspend due to suspend policy=never")
return
}
+ // Stop the X-server key auto-repeat (a main-thread Handler) BEFORE suspending the guest. Otherwise, if a key
+ // is still "held" when the guest is SIGSTOPped, the auto-repeat's next blocking ClientSocket.write to the
+ // non-draining guest hangs the UI thread -> ANR (seen when opening the SC editor with a key still down).
+ runCatching { xServerView?.getxServer()?.inputDeviceManager?.onGuestSuspended() }
PluviaApp.xEnvironment?.onPause()
PluviaApp.isOverlayPaused = true
}
@@ -1268,6 +1293,12 @@ fun XServerScreen(
true
}
+ // Steam Controller live editors: open the editor; on close they persist + call tritonMapper.reload()
+ // so the change applies to the running game with no relaunch.
+ QuickMenuAction.SC_ROOT -> { keepPausedForEditor = true; showScRoot = true; true }
+ QuickMenuAction.SC_BINDINGS -> { keepPausedForEditor = true; showScBindings = true; true }
+ QuickMenuAction.SC_LAYOUT -> { keepPausedForEditor = true; showScLayout = true; true }
+
QuickMenuAction.PERFORMANCE_HUD -> {
val enabled = !isPerformanceHudEnabled
isPerformanceHudEnabled = enabled
@@ -2158,7 +2189,7 @@ fun XServerScreen(
appId,
bootToContainer,
testGraphics,
- diagnostics,
+ PrefManager.wrapperDiagnostics,
xServerState,
envVars,
container,
@@ -2261,6 +2292,117 @@ fun XServerScreen(
},
)
+ // Steam Controller (Puck over USB-C, no root) driver — starts if a Puck is connected.
+ // Loads this container's per-game ScConfig (action sets / layers / mode-shift) if one exists,
+ // else falls back to the hardcoded default profile inside TritonMapper.
+ tritonMapper?.stop()
+ val scConfig = app.gamenative.steamcontroller.ScConfigStore.forKey(
+ context, appId,
+ )
+ // Step-6 menu HUD overlay (radial/touch ring/grid). Added above the game render; fail-safe so a
+ // UI issue can't block the controller. Falls back to a no-op overlay if attach fails.
+ val scMenuOverlay = runCatching {
+ app.gamenative.steamcontroller.ScMenuOverlayView(context).also { ov ->
+ // Resolve each menu's placement/size at draw time, per-menu (keyed by appId + menuId),
+ // falling back to the whole-HUD per-game/global placement. reload() refreshes it live.
+ ov.gameKey = appId
+ gameHost.addView(
+ ov,
+ FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ),
+ )
+ }
+ }.getOrNull() ?: app.gamenative.steamcontroller.NoOpScMenuOverlay
+ // Split-trackpad on-screen keyboard overlay (toggled by SHOW_KEYBOARD / the Steam button).
+ val scKeyboardOverlay = runCatching {
+ app.gamenative.steamcontroller.ScKeyboardOverlayView(context).also { kv ->
+ kv.setLayout(app.gamenative.steamcontroller.ScOverlayStore.forKeyboard(context, appId))
+ gameHost.addView(
+ kv,
+ FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ),
+ )
+ }
+ }.getOrNull() ?: app.gamenative.steamcontroller.NoOpScKeyboardOverlay
+ // Bridge: lets the BLE controller open + navigate the QuickMenu / in-game editors. The Triton
+ // isn't an Android input device, so we open the menu via the same back action physical pads use
+ // and inject focus-nav keys (DPAD/CENTER) / a back-press into Compose on the main thread.
+ val scMainHandler = android.os.Handler(android.os.Looper.getMainLooper())
+ val scCursor = app.gamenative.ui.component.dialog.ScCursorController(context)
+ val scUiBridge = object : app.gamenative.steamcontroller.ScUiBridge {
+ override fun isMenuCapturing(): Boolean =
+ showQuickMenu || keepPausedForEditor || showElementEditor || isEditMode
+ override fun openQuickMenu() {
+ scMainHandler.post { if (!showQuickMenu) gameBack() }
+ }
+ override fun moveCursor(dx: Int, dy: Int) {
+ scMainHandler.post {
+ // Only draw the nav cursor while a menu/editor is capturing; otherwise a stray move
+ // (or a leftover dot) must not attach to the game view. Detach on any out-of-capture move.
+ if (!isMenuCapturing()) { scCursor.detach(); return@post }
+ scCursor.move(app.gamenative.ui.component.dialog.ScNavDialogStack.topView() ?: view, dx, dy)
+ }
+ }
+ override fun cursorTap() {
+ scMainHandler.post {
+ if (!isMenuCapturing()) { scCursor.detach(); return@post }
+ scCursor.tap(app.gamenative.ui.component.dialog.ScNavDialogStack.topView() ?: view)
+ }
+ }
+ override fun hideCursor() { scMainHandler.post { scCursor.detach() } }
+ override fun nav(key: app.gamenative.steamcontroller.ScNavKey) {
+ scMainHandler.post {
+ val act = context as? ComponentActivity ?: return@post
+ val now = android.os.SystemClock.uptimeMillis()
+ // SC settings dialogs each live in their OWN window (Compose Dialog/AlertDialog), so
+ // nav events must go to the top open dialog's view, not the main Compose view. Falls
+ // back to the main view (`view`) for the QuickMenu, which is in the main window.
+ val target = app.gamenative.ui.component.dialog.ScNavDialogStack.topView() ?: view
+ if (key == app.gamenative.steamcontroller.ScNavKey.BACK) {
+ // Close the top dialog via its own dismiss (a synthetic KEYCODE_BACK does NOT reach
+ // a Compose dialog's onDismissRequest); fall back to the activity back for the menu.
+ if (!app.gamenative.ui.component.dialog.ScNavDialogStack.back()) {
+ act.onBackPressedDispatcher.onBackPressed()
+ }
+ return@post
+ }
+ val code = when (key) {
+ app.gamenative.steamcontroller.ScNavKey.UP -> KeyEvent.KEYCODE_DPAD_UP
+ app.gamenative.steamcontroller.ScNavKey.DOWN -> KeyEvent.KEYCODE_DPAD_DOWN
+ app.gamenative.steamcontroller.ScNavKey.LEFT -> KeyEvent.KEYCODE_DPAD_LEFT
+ app.gamenative.steamcontroller.ScNavKey.RIGHT -> KeyEvent.KEYCODE_DPAD_RIGHT
+ // Bumpers -> tab prev/next; the command picker listens for L1/R1 to flip tabs.
+ app.gamenative.steamcontroller.ScNavKey.TAB_PREV -> KeyEvent.KEYCODE_BUTTON_L1
+ app.gamenative.steamcontroller.ScNavKey.TAB_NEXT -> KeyEvent.KEYCODE_BUTTON_R1
+ app.gamenative.steamcontroller.ScNavKey.HELP -> KeyEvent.KEYCODE_BUTTON_Y // Y = Help
+ app.gamenative.steamcontroller.ScNavKey.CLOSE -> KeyEvent.KEYCODE_BUTTON_START // Start = close editor
+ // Triggers -> zoom in/out; the overlay placement editor listens for these to resize.
+ app.gamenative.steamcontroller.ScNavKey.ZOOM_IN -> KeyEvent.KEYCODE_ZOOM_IN
+ app.gamenative.steamcontroller.ScNavKey.ZOOM_OUT -> KeyEvent.KEYCODE_ZOOM_OUT
+ else -> KeyEvent.KEYCODE_DPAD_CENTER // SELECT
+ }
+ // Compose's focus system does its own DPAD focus traversal + DPAD_CENTER activation,
+ // independent of Android View focus (unlike Activity.dispatchKeyEvent, which routes to
+ // the focused game SurfaceView and never reaches Compose).
+ target.dispatchKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_DOWN, code, 0))
+ target.dispatchKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_UP, code, 0))
+ }
+ }
+ }
+ tritonMapper = app.gamenative.steamcontroller.TritonMapper(
+ context,
+ xServerView.getxServer(),
+ scConfig,
+ scMenuOverlay,
+ scKeyboardOverlay,
+ configKey = appId,
+ uiBridge = scUiBridge,
+ ).also { it.start() }
+
// Store profile for auto-show logic
loadedProfile = targetProfile
}
@@ -2587,6 +2729,7 @@ fun XServerScreen(
onFpsLimiterEnabledChanged = ::applyFpsLimiterEnabled,
onFpsLimiterChanged = ::applyFpsLimiterTarget,
hasPhysicalController = hasPhysicalController,
+ isSteamControllerLive = tritonMapper?.transportReady == true,
isTouchscreenModeActive = isTouchscreenModeActive,
onTouchGestureSettingsClick = { showTouchGestureDialog = true },
isShooterModeActive = isShooterModeActive,
@@ -2612,6 +2755,13 @@ fun XServerScreen(
if (shouldForceResumeOnMenuClose) {
forceResumeIfSuspended()
shouldForceResumeOnMenuClose = false
+ } else if (tritonMapper?.transportReady == true && !keepPausedForEditor && !isExiting.get()) {
+ // A BLE Steam Controller can't press the manual-resume button (it isn't an Android input
+ // device), so closing the menu with it would otherwise leave the game stuck paused. The
+ // controller user closing the menu = ready to play, so resume regardless of manual policy.
+ // Skip while exiting — EXIT_GAME already resumed + began teardown; a second onResume() here
+ // (fired by the close animation) races the teardown and can freeze the app.
+ forceResumeIfSuspended()
} else if (!keepPausedForEditor) {
resumeIfAllowedAfterOverlay()
}
@@ -2832,6 +2982,32 @@ fun XServerScreen(
}
}
+ // Steam Controller live editors (from the QuickMenu CONTROLLER tab). Each persists via ScConfigStore /
+ // ScTuningStore and calls tritonMapper.reload() so the change applies to the running game with no relaunch.
+ val scEditorDismiss: () -> Unit = {
+ keepPausedForEditor = false
+ runCatching { tritonMapper?.reload() }
+ // A BLE Steam Controller can't press the manual-resume button, so force-resume for SC sessions (otherwise
+ // closing an editor in manual-suspend policy would leave the game stuck paused).
+ if (tritonMapper?.transportReady == true) forceResumeIfSuspended() else resumeIfAllowedAfterOverlay()
+ }
+ // The SC live-editor dialogs (hub + sub-editors) are extracted into their own composable: XServerScreen is a huge
+ // function and was at the ART bytecode verifier's size limit (a VerifyError crashes launch when it grows), so
+ // editor UI must live outside this method.
+ ScLiveEditorDialogs(
+ appId = appId,
+ tritonMapper = tritonMapper,
+ showScRoot = showScRoot, onShowScRoot = { showScRoot = it },
+ showScBindings = showScBindings, onShowScBindings = { showScBindings = it },
+ showScLayout = showScLayout, onShowScLayout = { showScLayout = it },
+ showScKeyboard = showScKeyboard, onShowScKeyboard = { showScKeyboard = it },
+ showScConfigs = showScConfigs, onShowScConfigs = { showScConfigs = it },
+ scReturnToRoot = scReturnToRoot, onScReturnToRoot = { scReturnToRoot = it },
+ onFullDismiss = scEditorDismiss,
+ // Back from the SC hub returns to the QuickMenu (not the game): keep paused, show the QuickMenu again.
+ onRootBack = { keepPausedForEditor = false; showQuickMenu = true },
+ )
+
// var ranSetup by rememberSaveable { mutableStateOf(false) }
// LaunchedEffect(lifecycleOwner) {
// if (!ranSetup) {
@@ -2842,6 +3018,87 @@ fun XServerScreen(
// }
}
+/**
+ * The Steam Controller live-editor dialogs surfaced from the in-game QuickMenu: a single root hub that lists the
+ * editors (Bindings / Labels / Touchpad / Overlay) plus the sub-editor dialogs themselves. Extracted out of the
+ * giant [XServerScreen] composable because that method sits at the ART bytecode verifier's size limit — growing it
+ * (e.g. adding controller-nav focus handling here) trips a `VerifyError` that crashes on launch.
+ *
+ * Navigation model: opening a sub-editor from the hub sets [scReturnToRoot]; the sub-editor's dismiss then reopens
+ * the hub (back ONE level, staying paused + applying edits live) instead of resuming the game via [onFullDismiss].
+ * The hub itself requests initial focus so d-pad/stick nav has a starting control (otherwise the list looks
+ * un-navigable and must be touch-picked).
+ */
+@Composable
+private fun ScLiveEditorDialogs(
+ appId: String,
+ tritonMapper: app.gamenative.steamcontroller.TritonMapper?,
+ showScRoot: Boolean, onShowScRoot: (Boolean) -> Unit,
+ showScBindings: Boolean, onShowScBindings: (Boolean) -> Unit,
+ showScLayout: Boolean, onShowScLayout: (Boolean) -> Unit,
+ showScKeyboard: Boolean, onShowScKeyboard: (Boolean) -> Unit,
+ showScConfigs: Boolean, onShowScConfigs: (Boolean) -> Unit,
+ scReturnToRoot: Boolean, onScReturnToRoot: (Boolean) -> Unit,
+ onFullDismiss: () -> Unit,
+ /** Back from the ROOT hub → reopen the QuickMenu (not resume the game). */
+ onRootBack: () -> Unit,
+) {
+ // Close a sub-editor opened from the SC root hub: if we came from the hub, reopen it (back ONE level, stay
+ // paused, apply edits live); otherwise (opened directly) fall through to the full resume.
+ val scSubEditorDismiss: (close: () -> Unit) -> Unit = { close ->
+ close()
+ if (scReturnToRoot) {
+ onScReturnToRoot(false)
+ runCatching { tritonMapper?.reload() }
+ onShowScRoot(true)
+ } else {
+ onFullDismiss()
+ }
+ }
+ if (showScRoot) {
+ // The hub items (label + the action that opens that sub-editor). Each opens its editor with scReturnToRoot=true
+ // so B comes back here one level at a time. Rendered by [ScRootMenuDialog] so the hub shares the bindings
+ // editor's look (gradient selection ring, purple selected text, oval Back chip) and its controller nav.
+ val items: List Unit>> = listOf(
+ stringResource(R.string.sc_edit_configs) to { onShowScRoot(false); onScReturnToRoot(true); onShowScConfigs(true) },
+ stringResource(R.string.sc_edit_bindings) to { onShowScRoot(false); onScReturnToRoot(true); onShowScBindings(true) },
+ stringResource(R.string.sc_edit_layout) to { onShowScRoot(false); onScReturnToRoot(true); onShowScLayout(true) },
+ stringResource(R.string.sc_edit_keyboard) to { onShowScRoot(false); onScReturnToRoot(true); onShowScKeyboard(true) },
+ )
+ app.gamenative.ui.component.dialog.ScRootMenuDialog(
+ title = stringResource(R.string.sc_edit_root),
+ items = items,
+ onBack = { onShowScRoot(false); onRootBack() },
+ )
+ }
+ if (showScBindings) {
+ SteamControllerBindingEditorDialog(containerId = appId, onDismiss = { scSubEditorDismiss { onShowScBindings(false) } })
+ }
+ if (showScLayout) {
+ ScOverlayEditorDialog(
+ storeKey = appId,
+ isShared = false,
+ target = ScOverlayTarget.MENU,
+ onDismiss = { scSubEditorDismiss { onShowScLayout(false) } },
+ )
+ }
+ if (showScKeyboard) {
+ ScOverlayEditorDialog(
+ storeKey = appId,
+ isShared = false,
+ target = ScOverlayTarget.KEYBOARD,
+ onDismiss = { scSubEditorDismiss { onShowScKeyboard(false) } },
+ )
+ }
+ if (showScConfigs) {
+ ScConfigManagerDialog(
+ storeKey = appId,
+ onChanged = { runCatching { tritonMapper?.reload() } },
+ onDismiss = { scSubEditorDismiss { onShowScConfigs(false) } },
+ )
+ }
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EditModeToolbar(
diff --git a/app/src/main/java/app/gamenative/utils/SteamControllerVdfUtils.kt b/app/src/main/java/app/gamenative/utils/SteamControllerVdfUtils.kt
index 0715044492..3fd7c1947b 100644
--- a/app/src/main/java/app/gamenative/utils/SteamControllerVdfUtils.kt
+++ b/app/src/main/java/app/gamenative/utils/SteamControllerVdfUtils.kt
@@ -1,7 +1,32 @@
package app.gamenative.utils
+import app.gamenative.steamcontroller.Activator
+import app.gamenative.steamcontroller.Binding
+import app.gamenative.steamcontroller.DpadLayout
+import app.gamenative.steamcontroller.GyroAccel
+import app.gamenative.steamcontroller.GyroActivation
+import app.gamenative.steamcontroller.GyroGate
+import app.gamenative.steamcontroller.GyroMode
+import app.gamenative.steamcontroller.LayerOpType
+import app.gamenative.steamcontroller.MacroCommand
+import app.gamenative.steamcontroller.MenuSlot
+import app.gamenative.steamcontroller.PadMode
+import app.gamenative.steamcontroller.ResponseCurve
+import app.gamenative.steamcontroller.ScConfig
+import app.gamenative.steamcontroller.ScOutput
+import app.gamenative.steamcontroller.ScProfile
+import app.gamenative.steamcontroller.Stick
+import app.gamenative.steamcontroller.StickMode
+import app.gamenative.steamcontroller.TriggerAxis
+import app.gamenative.steamcontroller.TriggerMode
+import app.gamenative.steamcontroller.TritonProtocol
+import com.winlator.inputcontrols.ExternalController
+import com.winlator.xserver.Pointer
+import com.winlator.xserver.XKeycode
import java.nio.file.Files
import java.nio.file.Path
+import kotlin.math.ceil
+import kotlin.math.sqrt
import timber.log.Timber
object SteamControllerVdfUtils {
@@ -214,6 +239,877 @@ object SteamControllerVdfUtils {
}
}
+/**
+ * Imports a Steam Input `.vdf` controller config (`controller_mappings`) into our [ScProfile] model — the
+ * "coverage guarantee" of docs/STEAM-INPUT-COVERAGE.md: if we can parse any Triton config into the profile
+ * model and run it through [app.gamenative.steamcontroller.ProfileInterpreter], we cover the practical surface.
+ *
+ * Handles both schema variants the configurator writes:
+ * - **v3** (current Triton): `preset { group_source_bindings }`, group `inputs { { activators {
+ * { bindings { binding } settings } } } }`. (e.g. `chord_triton.vdf`, the bundled xboxone templates.)
+ * - **v2** (older): top-level `group_source_bindings` + `switch_bindings`, flat group `bindings {
+ * }`. (e.g. `gamepad_joystick.vdf`.)
+ *
+ * Maps each `group_source_bindings` *source* (button_diamond / switch / dpad / joystick / right_joystick /
+ * left_trackpad / right_trackpad / left_trigger / right_trigger / gyro) onto the matching [ScProfile] field,
+ * each binding string onto an [ScOutput], and each activator onto an [Activator]. Features our model can't yet
+ * represent (stick-as-dpad, absolute/region mouse precision, button_pad grids, most `controller_action`s) are
+ * logged and left unbound rather than mis-imported — keeping the importer honest against the coverage matrix.
+ *
+ * Reuses the file-private [VdfParser]. Pure logic, unit-tested on PC (SteamControllerProfileImporterTest).
+ */
+object SteamControllerProfileImporter {
+ private const val TAG = "ScVdfImport"
+
+ /** Parse [vdfText] into a profile, using the named [presetName] action set (falls back to the first preset). */
+ fun import(vdfText: String, presetName: String = "Default"): ScProfile {
+ val cm = parseMappings(vdfText) ?: return ScProfile(name = "Imported (empty)")
+ val groupsById = indexGroups(cm)
+ val presets = cm.getObjects("preset")
+ val preset = presets.firstOrNull { it.getString("name").equals(presetName, ignoreCase = true) }
+ ?: presets.firstOrNull()
+ val name = cm.getString("title")?.takeIf { it.isNotBlank() && !it.startsWith("#") } ?: "Imported"
+ return buildProfile(cm, groupsById, preset, name).first
+ }
+
+ /**
+ * Import **every action set** in the config into a name→profile map (ordered as the config lists them).
+ * Steam "action sets" (the `actions` block — e.g. KSP's Menu/Flight/Docking/EVA, ToME4's Main/Extra) each
+ * have their own `preset`/`group_source_bindings`; this decodes all of them so a future action-set switcher
+ * (build step 3) or the binding-editor can present them. Falls back to a single "Default" entry when the
+ * config has no `actions` block (plain templates). Pure data — does not wire any runtime switching.
+ */
+ fun importActionSets(vdfText: String): LinkedHashMap {
+ val out = LinkedHashMap()
+ val cm = parseMappings(vdfText) ?: return out
+ val groupsById = indexGroups(cm)
+ val presets = cm.getObjects("preset")
+ val actionKeys = cm.getObject("actions")?.keys().orEmpty()
+ if (actionKeys.isEmpty()) {
+ out["Default"] = import(vdfText)
+ return out
+ }
+ for (key in actionKeys) {
+ val preset = presets.firstOrNull { it.getString("name").equals(key, ignoreCase = true) }
+ if (preset == null) { Timber.tag(TAG).d("action set '$key' has no matching preset -> skipped"); continue }
+ val title = cm.getObject("actions")?.getObject(key)?.getString("title")
+ ?.takeIf { it.isNotBlank() && !it.startsWith("#") } ?: key
+ out[title] = buildProfile(cm, groupsById, preset, title).first
+ }
+ return out
+ }
+
+ /**
+ * Import the whole config as an [ScConfig]: every `preset` decoded and **keyed by its Steam preset id**
+ * (what `CHANGE_PRESET` / [ScOutput.SwitchActionSet] target), plus the launch set. Feed this to
+ * [app.gamenative.steamcontroller.ProfileInterpreter] for config-driven action-set switching.
+ */
+ fun importConfig(vdfText: String): ScConfig {
+ val cm = parseMappings(vdfText) ?: return ScConfig(emptyMap(), "")
+ val groupsById = indexGroups(cm)
+ val presets = cm.getObjects("preset")
+ val sets = LinkedHashMap()
+ val setSources = LinkedHashMap>()
+ for (preset in presets) {
+ val id = preset.getString("id") ?: continue
+ val name = preset.getString("name")?.takeIf { it.isNotBlank() && !it.startsWith("#") } ?: "Set $id"
+ val (profile, sources) = buildProfile(cm, groupsById, preset, name)
+ sets[id] = profile
+ setSources[id] = sources
+ }
+ val defaultId = presets.firstOrNull { it.getString("name").equals("Default", ignoreCase = true) }
+ ?.getString("id")
+ ?: presets.firstOrNull()?.getString("id")
+ ?: ""
+ // Decode each mode_shift target group as its single source (momentary overlay, merged while held).
+ val shiftOverlays = LinkedHashMap()
+ sets.values.forEach { set ->
+ set.buttons.values.forEach { b ->
+ val o = b.output
+ if (o is ScOutput.ModeShift && !shiftOverlays.containsKey(o.groupId)) {
+ groupsById[o.groupId]?.let { shiftOverlays[o.groupId] = decodeSingleSource(o.source, it, groupsById) }
+ }
+ }
+ }
+ return ScConfig(sets, defaultId, setSources, shiftOverlays)
+ }
+
+ private fun parseMappings(vdfText: String): VdfObject? {
+ val cm = VdfParser(vdfText).parse().getObject("controller_mappings")
+ if (cm == null) Timber.tag(TAG).w("No controller_mappings block; returning empty profile")
+ return cm
+ }
+
+ private fun indexGroups(cm: VdfObject): LinkedHashMap {
+ val groupsById = LinkedHashMap()
+ cm.getObjects("group").forEach { g -> g.getString("id")?.let { groupsById[it] = g } }
+ return groupsById
+ }
+
+ /**
+ * Decode one action set (a [preset]'s `group_source_bindings`) into a profile named [name], plus the set of
+ * sources it defines (needed for action-layer merging — see [mergeProfiles]).
+ */
+ private fun buildProfile(
+ cm: VdfObject,
+ groupsById: Map,
+ preset: VdfObject?,
+ name: String,
+ ): Pair> {
+ // Resolve which group drives each physical source (active bindings only).
+ val gsb = preset?.getObject("group_source_bindings") ?: cm.getObject("group_source_bindings")
+ val sourceGroup = LinkedHashMap()
+ gsb?.stringEntries()?.forEach { (groupId, spec) ->
+ val parts = spec.trim().split(Regex("\\s+"))
+ val source = parts.getOrNull(0)?.lowercase() ?: return@forEach
+ val state = parts.getOrNull(1)?.lowercase()
+ if (state != "active") return@forEach
+ // Only the *base* layer (e.g. "right_trackpad active"). A 3rd qualifier marks a mode-shift / action
+ // layer variant ("right_trackpad active modeshift") whose group must NOT clobber the base binding —
+ // those belong to the action-sets/mode-shift feature (step 3, still TODO), so defer + log them.
+ if (parts.size > 2) {
+ Timber.tag(TAG).d("source $source group $groupId is a '${parts.drop(2).joinToString(" ")}' layer -> deferred (action-set/mode-shift TODO)")
+ return@forEach
+ }
+ val group = groupsById[groupId]
+ if (group == null) Timber.tag(TAG).d("source $source -> missing group $groupId")
+ else sourceGroup[source] = resolveReference(group, groupsById)
+ }
+
+ val buttons = LinkedHashMap()
+ var leftStick: StickMode = StickMode.None
+ var rightStick: StickMode = StickMode.None
+ var leftPad: PadMode = PadMode.None
+ var rightPad: PadMode = PadMode.None
+ var leftTrigger: TriggerMode = TriggerMode.Axis(TriggerAxis.GAMEPAD_L2)
+ var rightTrigger: TriggerMode = TriggerMode.Axis(TriggerAxis.GAMEPAD_R2)
+ var gyro: GyroMode = GyroMode.None
+
+ sourceGroup["button_diamond"]?.let { addDigitalInputs(it, DIAMOND_MAP, buttons) }
+ sourceGroup["switch"]?.let { addDigitalInputs(it, SWITCH_MAP, buttons) }
+ cm.getObject("switch_bindings")?.let { addDigitalInputs(it, SWITCH_MAP, buttons) } // v2
+ sourceGroup["dpad"]?.let { addDigitalInputs(it, DPAD_MAP, buttons) }
+ sourceGroup["joystick"]?.let { leftStick = importStick(it, TritonProtocol.BTN_L3, TritonProtocol.BTN_LSTICK_TOUCH, Stick.LEFT, buttons) }
+ sourceGroup["right_joystick"]?.let { rightStick = importStick(it, TritonProtocol.BTN_R3, TritonProtocol.BTN_RSTICK_TOUCH, Stick.RIGHT, buttons) }
+ sourceGroup["left_trackpad"]?.let {
+ leftPad = importPad(it, TritonProtocol.BTN_LPAD_CLICK, TritonProtocol.BTN_LPAD_TOUCH, buttons)
+ }
+ sourceGroup["right_trackpad"]?.let {
+ rightPad = importPad(it, TritonProtocol.BTN_RPAD_CLICK, TritonProtocol.BTN_RPAD_TOUCH, buttons)
+ }
+ sourceGroup["left_trigger"]?.let {
+ leftTrigger = importTrigger(it, TritonProtocol.BTN_LTRIG_CLICK, TriggerAxis.GAMEPAD_L2, buttons)
+ }
+ sourceGroup["right_trigger"]?.let {
+ rightTrigger = importTrigger(it, TritonProtocol.BTN_RTRIG_CLICK, TriggerAxis.GAMEPAD_R2, buttons)
+ }
+ sourceGroup["gyro"]?.let { gyro = importGyro(it) }
+
+ val profile = ScProfile(
+ name = name,
+ buttons = buttons,
+ leftStick = leftStick,
+ rightStick = rightStick,
+ leftPad = leftPad,
+ rightPad = rightPad,
+ leftTrigger = leftTrigger,
+ rightTrigger = rightTrigger,
+ gyro = gyro,
+ )
+ return profile to sourceGroup.keys.toSet()
+ }
+
+ /** Decode a single [group] as one [source] into a partial profile (only that source's field set). For mode-shift. */
+ private fun decodeSingleSource(source: String, group: VdfObject, groupsById: Map): ScProfile {
+ val g = resolveReference(group, groupsById)
+ val buttons = LinkedHashMap()
+ var leftStick: StickMode = StickMode.None
+ var rightStick: StickMode = StickMode.None
+ var leftPad: PadMode = PadMode.None
+ var rightPad: PadMode = PadMode.None
+ var leftTrigger: TriggerMode = TriggerMode.Axis(TriggerAxis.GAMEPAD_L2)
+ var rightTrigger: TriggerMode = TriggerMode.Axis(TriggerAxis.GAMEPAD_R2)
+ var gyro: GyroMode = GyroMode.None
+ when (source) {
+ "button_diamond" -> addDigitalInputs(g, DIAMOND_MAP, buttons)
+ "switch" -> addDigitalInputs(g, SWITCH_MAP, buttons)
+ "dpad" -> addDigitalInputs(g, DPAD_MAP, buttons)
+ "joystick" -> leftStick = importStick(g, TritonProtocol.BTN_L3, TritonProtocol.BTN_LSTICK_TOUCH, Stick.LEFT, buttons)
+ "right_joystick" -> rightStick = importStick(g, TritonProtocol.BTN_R3, TritonProtocol.BTN_RSTICK_TOUCH, Stick.RIGHT, buttons)
+ "left_trackpad" -> leftPad = importPad(g, TritonProtocol.BTN_LPAD_CLICK, TritonProtocol.BTN_LPAD_TOUCH, buttons)
+ "right_trackpad" -> rightPad = importPad(g, TritonProtocol.BTN_RPAD_CLICK, TritonProtocol.BTN_RPAD_TOUCH, buttons)
+ "left_trigger" -> leftTrigger = importTrigger(g, TritonProtocol.BTN_LTRIG_CLICK, TriggerAxis.GAMEPAD_L2, buttons)
+ "right_trigger" -> rightTrigger = importTrigger(g, TritonProtocol.BTN_RTRIG_CLICK, TriggerAxis.GAMEPAD_R2, buttons)
+ "gyro" -> gyro = importGyro(g)
+ else -> Timber.tag(TAG).d("mode_shift: unhandled source '$source'")
+ }
+ return ScProfile(
+ name = "shift:$source", buttons = buttons, leftStick = leftStick, rightStick = rightStick,
+ leftPad = leftPad, rightPad = rightPad, leftTrigger = leftTrigger, rightTrigger = rightTrigger, gyro = gyro,
+ )
+ }
+
+ /**
+ * Follow `mode "reference"` groups to their target. Steam Deck configs (e.g. Delf's ToME4 layout) share a
+ * group definition across action sets/layers via `settings { referenced_mode "" }` instead of inlining
+ * it; resolving the chain lets the real referenced mode/inputs flow into the importer. Guarded against cycles.
+ */
+ private fun resolveReference(group: VdfObject, groupsById: Map, depth: Int = 0): VdfObject {
+ if (depth > 8 || group.getString("mode")?.lowercase() != "reference") return group
+ val target = group.getObject("settings")?.getString("referenced_mode")
+ val tg = target?.let { groupsById[it] } ?: return group
+ Timber.tag(TAG).d("resolving reference group -> $target")
+ return resolveReference(tg, groupsById, depth + 1)
+ }
+
+ // ---- digital sources (buttons) ------------------------------------------------------------------
+
+ /** input-name -> SC button bit, for the four face buttons (button_diamond source). */
+ private val DIAMOND_MAP = mapOf(
+ "button_a" to TritonProtocol.BTN_A, "button_b" to TritonProtocol.BTN_B,
+ "button_x" to TritonProtocol.BTN_X, "button_y" to TritonProtocol.BTN_Y,
+ )
+
+ /**
+ * System/bumper/paddle buttons (switch source). Start/Back: Steam's `button_menu` is the Start-side
+ * button (our BTN_VIEW, =Start per SDL) and `button_escape` is the Back/≡-side button (our BTN_MENU).
+ * Paddles: back_left/right = lower L4/R4, *_upper = L5/R5. (TODO: verify the Start/Back pairing on-device.)
+ */
+ private val SWITCH_MAP = mapOf(
+ "button_escape" to TritonProtocol.BTN_MENU,
+ "button_menu" to TritonProtocol.BTN_VIEW,
+ "left_bumper" to TritonProtocol.BTN_LBUMPER,
+ "right_bumper" to TritonProtocol.BTN_RBUMPER,
+ "button_back_left" to TritonProtocol.BTN_L4,
+ "button_back_right" to TritonProtocol.BTN_R4,
+ "button_back_left_upper" to TritonProtocol.BTN_L5,
+ "button_back_right_upper" to TritonProtocol.BTN_R5,
+ )
+
+ /** Physical d-pad source. Our GamepadDpad index order is 0=up,1=right,2=down,3=left. */
+ private val DPAD_MAP = mapOf(
+ "dpad_north" to TritonProtocol.BTN_DPAD_UP, "dpad_south" to TritonProtocol.BTN_DPAD_DOWN,
+ "dpad_east" to TritonProtocol.BTN_DPAD_RIGHT, "dpad_west" to TritonProtocol.BTN_DPAD_LEFT,
+ )
+
+ /** Fill [buttons] from a group's inputs (v3 activators or v2 flat bindings) using [bitMap]. */
+ private fun addDigitalInputs(group: VdfObject, bitMap: Map, buttons: MutableMap) {
+ // v3: inputs { { activators ... } }
+ group.getObject("inputs")?.objectEntries()?.forEach { (name, inputObj) ->
+ val bit = bitMap[name.lowercase()]
+ if (bit == null) { Timber.tag(TAG).d("no bit for input '$name'"); return@forEach }
+ resolveInput(inputObj)?.let { buttons[bit] = it }
+ }
+ // v2: bindings { }
+ group.getObject("bindings")?.stringEntries()?.forEach { (name, str) ->
+ val bit = bitMap[name.lowercase()] ?: return@forEach
+ parseBindingOutput(str)?.let { buttons[bit] = Binding(it) }
+ }
+ }
+
+ // ---- analog sources -----------------------------------------------------------------------------
+
+ private fun importStick(group: VdfObject, clickBit: Int, touchBit: Int, stick: Stick, buttons: MutableMap): StickMode {
+ extractInput(group, "click")?.let { buttons[clickBit] = it }
+ extractInput(group, "touch")?.let { buttons[touchBit] = it } // surface-touch binding (fires on capacitive touch)
+ val s = group.getObject("settings")
+ fun joystick() = StickMode.JoystickMove(
+ stick,
+ invertY = readInvertY(s, default = true),
+ deadzone = readDeadzone(s, default = 0.12f),
+ curve = readCurve(s),
+ )
+ return when (val mode = group.getString("mode")?.lowercase().orEmpty()) {
+ "joystick_move" -> joystick()
+ "joystick_camera" -> joystick() // camera-vs-move differ only by feel here; response curve now carried
+ // Stick→mouse. `joystick_mouse` is the stick name, `mouse_joystick` the pad name — accept both here so a
+ // shared/`reference`d group that carries the pad name on a stick source still maps (never silently drops).
+ "joystick_mouse", "mouse_joystick" -> StickMode.Mouse(
+ sensitivity = 12f * readSensScale(s),
+ deadzone = readDeadzone(s, default = 0.10f),
+ )
+ "flickstick" -> StickMode.FlickStick(deadzone = readDeadzone(s, default = 0.20f))
+ "dpad" -> StickMode.DPad(
+ up = dirOut(group, "dpad_north"), down = dirOut(group, "dpad_south"),
+ left = dirOut(group, "dpad_west"), right = dirOut(group, "dpad_east"),
+ deadzone = readDeadzone(s, default = 0.35f),
+ layout = readDpadLayout(s), overlap = readOverlap(s),
+ ).takeIf { listOf(it.up, it.down, it.left, it.right).any { o -> o != ScOutput.None } } ?: StickMode.None
+ "radial_menu" -> parseRadial(group).let {
+ if (it.ring.isEmpty()) StickMode.None // HOLD by default (movement radial)
+ else StickMode.RadialMenu(it.ring, center = it.center, directional = it.directional)
+ }
+ "touch_menu" -> parseMenuSlots(group).let {
+ if (it.isEmpty()) StickMode.None else gridDims(it.size).let { (c, r) -> StickMode.TouchMenu(it, c, r) }
+ }
+ "", "disabled" -> StickMode.None
+ else -> { Timber.tag(TAG).d("unsupported stick mode '$mode' -> None"); StickMode.None }
+ }
+ }
+
+ private fun importPad(group: VdfObject, clickBit: Int, touchBit: Int, buttons: MutableMap): PadMode {
+ extractInput(group, "click")?.let { buttons[clickBit] = it }
+ extractInput(group, "touch")?.let { buttons[touchBit] = it } // surface-touch binding (fires on capacitive touch)
+ val s = group.getObject("settings")
+ fun mouse() = PadMode.Mouse(
+ sensitivity = (1.0f / 70f) * readSensScale(s), invertY = readInvertY(s, default = true),
+ rotation = s?.getString("rotation")?.toFloatOrNull() ?: 0f,
+ horizScale = readPctScale(s, "sensitivity_horiz_scale"), vertScale = readPctScale(s, "sensitivity_vert_scale"),
+ )
+ return when (val mode = group.getString("mode")?.lowercase().orEmpty()) {
+ "dpad" -> PadMode.DPad(
+ up = dirOut(group, "dpad_north"), down = dirOut(group, "dpad_south"),
+ left = dirOut(group, "dpad_west"), right = dirOut(group, "dpad_east"),
+ deadzone = readDeadzone(s, default = 0.35f),
+ layout = readDpadLayout(s), overlap = readOverlap(s),
+ )
+ "scrollwheel" -> PadMode.ScrollWheel()
+ // Mouse Region (Steam `mouse_region`): finger position → 1:1 screen position within a region. Params
+ // (confirmed): position_x/y (center %, def 50), scale (size %, def 80), scale_x/y (def 100), invert_x/y.
+ // NOTE: `absolute_mouse` is NOT this — it's Steam's legacy name for the standard RELATIVE trackpad mouse
+ // (carries As-Mouse settings: sensitivity/trackball/friction/acceleration, no region), handled below.
+ "mouse_region" -> {
+ fun pct(key: String, def: Float) = (s?.getString(key)?.toFloatOrNull() ?: def) / 100f
+ val scale = pct("scale", 80f)
+ PadMode.AbsoluteMouse(
+ centerX = pct("position_x", 50f), centerY = pct("position_y", 50f),
+ sizeX = scale * pct("scale_x", 100f), sizeY = scale * pct("scale_y", 100f),
+ invertX = s?.getString("invert_x") == "1", invertY = s?.getString("invert_y") == "1",
+ rotation = s?.getString("rotation")?.toFloatOrNull() ?: 0f,
+ )
+ }
+ // Single-button pad (whole surface = one button). The vdf binds it on the "click" input; importPad
+ // already put that in buttons[clickBit] — move it into the mode so it fires on the pad (avoid double-bind).
+ "single_button" -> {
+ val out = buttons.remove(clickBit)?.output ?: extractInput(group, "click")?.output ?: ScOutput.None
+ PadMode.SingleButton(out, onClick = readRequiresClick(s))
+ }
+ // Directional swipe (Steam `2dscroll`): flick a cardinal direction → pulse that dir's output. Uses the
+ // dpad_* inputs, like a d-pad, but swipe-triggered.
+ "2dscroll" -> PadMode.DirectionalSwipe(
+ up = dirOut(group, "dpad_north"), down = dirOut(group, "dpad_south"),
+ left = dirOut(group, "dpad_west"), right = dirOut(group, "dpad_east"),
+ )
+ // Pad-as-joystick: absolute finger position drives a virtual XInput stick (recenters on lift). Steam's
+ // `output_joystick` = 1/2/3 = Left/Right/mouse (confirmed key). Absent → default RIGHT (camera): a trackpad
+ // usually augments look, the physical stick covers movement. ponytail: absent-default is a heuristic; validate.
+ "joystick_move" -> PadMode.Joystick(
+ stick = if (s?.getString("output_joystick") == "1") Stick.LEFT else Stick.RIGHT,
+ invertY = readInvertY(s, default = true),
+ deadzone = readDeadzone(s, default = 0.12f),
+ )
+ // Mouse Joystick: pad = self-centering joystick that drives the mouse (displacement from center = velocity).
+ "mouse_joystick", "joystick_mouse" -> PadMode.MouseJoystick(
+ sensitivity = 20f * readSensScale(s), invertY = readInvertY(s, default = false),
+ )
+ // Relative trackpad mouse (drag = delta). `absolute_mouse` = Steam's legacy name for THIS (NOT a region —
+ // that's `mouse_region`, handled above). Only `mouse_region` is absolute.
+ "mouse", "relative_mouse", "absolute_mouse" -> mouse()
+ // Overlay-tier menus: the selection LOGIC is built (radial = angle→slot, touch = grid cell→slot,
+ // commit pulses the slot). The visual ring/grid HUD is the step-6 overlay (separate). hotbar ≈ touch grid.
+ "touch_menu", "radial_menu", "hotbar" -> importMenu(group, mode)
+ // button_pad: a blind grid. Reuse the touch_menu slot naming if present; else None. (Rare in the wild.)
+ "button_pad" -> {
+ val menu = importMenu(group, "touch_menu")
+ if (menu is PadMode.TouchMenu) {
+ PadMode.ButtonPadGrid(menu.cols, menu.rows, menu.slots.map { it.binding.output }, onClick = false)
+ } else { Timber.tag(TAG).d("pad mode 'button_pad' has no parseable slots -> None"); PadMode.None }
+ }
+ "", "disabled" -> PadMode.None
+ else -> { Timber.tag(TAG).d("unsupported pad mode '$mode' -> None"); PadMode.None }
+ }
+ }
+
+ /**
+ * Parse a radial_menu/touch_menu/hotbar group into its [PadMode]. Slots are the ordered `touch_menu_button_N`
+ * inputs (both menu types use that key naming), each resolved to a [Binding] + display label. Radial → ring;
+ * touch/hotbar → a near-square grid (row 0 = top). Returns [PadMode.None] if no slots resolve.
+ */
+ private fun importMenu(group: VdfObject, mode: String): PadMode {
+ // requires_click=1 → commit only on a pad click; =0/absent → release-style ("point and release"). Either
+ // way a click still commits (the interpreter treats click as a universal commit); this flag only controls
+ // whether disengaging (lifting the finger) also commits. Steam's menus default to release-style.
+ val onClick = readRequiresClick(group.getObject("settings"))
+ if (mode == "radial_menu") {
+ val r = parseRadial(group)
+ if (r.ring.isEmpty()) { Timber.tag(TAG).d("radial_menu group has no resolvable ring slots -> None"); return PadMode.None }
+ return PadMode.RadialMenu(r.ring, onClick = onClick, center = r.center, directional = r.directional)
+ }
+ val slots = parseMenuSlots(group)
+ if (slots.isEmpty()) {
+ Timber.tag(TAG).d("$mode group has no resolvable slots -> None")
+ return PadMode.None
+ }
+ val (cols, rows) = gridDims(slots.size)
+ return PadMode.TouchMenu(slots, cols, rows, onClick = onClick)
+ }
+
+ /** Ordered `touch_menu_button_N` slots (both menu kinds use that naming) → [MenuSlot]s with display labels. */
+ private fun parseMenuSlots(group: VdfObject): List =
+ group.getObject("inputs")?.objectEntries()
+ ?.filter { it.first.startsWith("touch_menu_button", ignoreCase = true) }
+ ?.sortedBy { it.first.substringAfterLast('_').toIntOrNull() ?: Int.MAX_VALUE }
+ ?.mapNotNull { (_, obj) -> resolveInput(obj)?.let { MenuSlot(it, menuLabelOf(obj)) } }
+ ?: emptyList()
+
+ /** A radial split into its ring + center: Steam's `touch_menu_button_0` is the radial CENTER (often a neutral/
+ * no-op like the movement radial's `mouse_delta 0 0`); buttons 1.. are the ring, in order. [directional] flags
+ * a movement radial — an 8-slot ring all bound to arrow/keypad direction keys — so the HUD can label it ↑↗→… */
+ private class RadialParse(val ring: List, val center: MenuSlot?, val directional: Boolean)
+
+ private fun parseRadial(group: VdfObject): RadialParse {
+ val entries = group.getObject("inputs")?.objectEntries()
+ ?.filter { it.first.startsWith("touch_menu_button", ignoreCase = true) }
+ ?.sortedBy { it.first.substringAfterLast('_').toIntOrNull() ?: Int.MAX_VALUE }
+ ?: emptyList()
+ var center: MenuSlot? = null
+ val ring = ArrayList()
+ for ((name, obj) in entries) {
+ val slot = resolveInput(obj)?.let { MenuSlot(it, menuLabelOf(obj)) } ?: continue
+ if (name.substringAfterLast('_').toIntOrNull() == 0) center = slot else ring.add(slot)
+ }
+ // The center (button_0) is the visual hub; the actual center ACTION is the group's `click` input (e.g.
+ // ToME4 stick-click = "Wait a turn"). Label the center from that click action when button_0 has no label.
+ val clickLabel = group.getObject("inputs")?.objectEntries()
+ ?.firstOrNull { it.first.equals("click", ignoreCase = true) }
+ ?.let { menuLabelOf(it.second) }?.takeIf { it.isNotBlank() }
+ val labeledCenter = center?.let { if (it.label.isBlank() && clickLabel != null) MenuSlot(it.binding, clickLabel) else it }
+ val directional = ring.size == 8 && ring.all { isDirectionalKey(it.binding.output) }
+ return RadialParse(ring, labeledCenter, directional)
+ }
+
+ /** A single arrow/keypad direction key (the building blocks of a movement radial's ring). */
+ private fun isDirectionalKey(out: ScOutput?): Boolean {
+ val keys = (out as? ScOutput.Key)?.keys ?: return false
+ if (keys.size != 1) return false
+ val n = keys[0].name
+ return n in DIRECTION_KEYS || n.startsWith("KEY_KP_")
+ }
+
+ private val DIRECTION_KEYS = setOf("KEY_UP", "KEY_DOWN", "KEY_LEFT", "KEY_RIGHT")
+
+ /** Near-square grid for a touch/hotbar menu of [n] slots. */
+ private fun gridDims(n: Int): Pair {
+ val cols = ceil(sqrt(n.toDouble())).toInt().coerceAtLeast(1)
+ val rows = ceil(n.toDouble() / cols).toInt().coerceAtLeast(1)
+ return cols to rows
+ }
+
+ /** A menu slot's display label, from its binding metadata ("key_press P, Level Up, icon, colors" -> "Level Up"). */
+ private fun menuLabelOf(inputObj: VdfObject): String {
+ val raw = inputObj.getObject("activators")?.objectValues()
+ ?.firstNotNullOfOrNull { it.getObject("bindings")?.getStrings("binding")?.firstOrNull() } ?: return ""
+ return raw.split(',').getOrNull(1)?.trim().orEmpty()
+ }
+
+ private fun importTrigger(
+ group: VdfObject,
+ clickBit: Int,
+ axis: TriggerAxis,
+ buttons: MutableMap,
+ ): TriggerMode {
+ val edge = extractInput(group, "edge")?.output
+ if (edge != null && edge != ScOutput.None) {
+ // Digital full-pull binding -> staged (keep the analog axis live too so games still see the trigger).
+ return TriggerMode.Staged(soft = ScOutput.None, full = edge, axis = axis)
+ }
+ val clickRaw = rawBindingOf(group, "click")
+ val clickIsAnalogAxis = clickRaw?.contains("TRIGGER_", ignoreCase = true) == true
+ if (!clickIsAnalogAxis) extractInput(group, "click")?.let { buttons[clickBit] = it }
+ return TriggerMode.Axis(axis)
+ }
+
+ private fun importGyro(group: VdfObject): GyroMode {
+ val s = group.getObject("settings")
+ // Confirmed vdf modes: gyro_to_mouse, gyro_to_joystick(_camera)/_deflection.
+ val gate = gyroGate(s)
+ val activation = readGyroActivation(s)
+ return when (val mode = group.getString("mode")?.lowercase().orEmpty()) {
+ "gyro_to_mouse", "mouse", "absolute_mouse", "mouse_region" ->
+ GyroMode.Mouse(
+ (1.0f / 900f) * readSensScale(s), gate, activation = activation,
+ speedDeadzone = s?.getString("gyro_speed_deadzone")?.toFloatOrNull() ?: 0f, // raw units
+ precisionSpeed = s?.getString("gyro_precision_speed")?.toFloatOrNull() ?: 0f, // raw units
+ accel = readGyroAccel(s), hvMixer = readGyroMixer(s),
+ )
+ "gyro_to_joystick", "gyro_to_joystick_camera", "gyro_to_joystick_deflection" -> {
+ // Deflection integrates gyro angle each frame (held position), so its base scale is ~10× smaller than
+ // camera's per-frame rate→deflection to land in the same feel range. Both feel-tuned on-device.
+ val deflection = mode == "gyro_to_joystick_deflection"
+ // Steam `gyro_to_joystick_power_curve` is the exponent ×100 (400 = 4.0 relaxed; def 100 = 1.0 linear).
+ val powerCurve = (s?.getString("gyro_to_joystick_power_curve")?.toFloatOrNull()?.div(100f) ?: 1f)
+ .coerceIn(0.1f, 4f)
+ GyroMode.Joystick(
+ stick = if (s?.getString("output_joystick") == "1") Stick.LEFT else Stick.RIGHT,
+ sensitivity = (if (deflection) 1.0f / 60000f else 1.0f / 6000f) * readSensScale(s),
+ gate = gate, deflection = deflection, activation = activation, powerCurve = powerCurve,
+ )
+ }
+ "", "disabled" -> GyroMode.None
+ else -> { Timber.tag(TAG).d("unsupported gyro mode '$mode' -> None"); GyroMode.None }
+ }
+ }
+
+ /** The four thumb-surface touch bits in Steam's `gyro_ratchet_button_mask` (from a real 4-touch export 2026-07-06:
+ * bits 19,20,46,47 = L/R pad + L/R stick touch, collectively). Which individual bit is which surface isn't yet
+ * pinned (needs single-surface captures), so a touch-covering mask maps to the "any touch" gate. */
+ private val GYRO_TOUCH_MASK = (1L shl 19) or (1L shl 20) or (1L shl 46) or (1L shl 47) // = 211106234105856
+
+ /** Resolve a gyro group's activation gate. `gyro_ratchet_button_mask` covering any touch surface → touch-to-aim;
+ * otherwise Steam's common grip default. (Grip/button bit positions in the mask aren't captured yet.) */
+ private fun gyroGate(s: VdfObject?): GyroGate {
+ val mask = s?.getString("gyro_ratchet_button_mask")?.toLongOrNull() ?: 0L
+ return if (mask != 0L && (mask and GYRO_TOUCH_MASK) != 0L) GyroGate.ANY_TOUCH else GyroGate.EITHER_GRIP
+ }
+
+ /** Steam Acceleration (`acceleration` 0..3 = Off/Linear/Relaxed/Aggressive; UI order). ponytail: order inferred
+ * from the UI; confirm against a labeled export if a curve feels off. */
+ private fun readGyroAccel(s: VdfObject?): GyroAccel = when (s?.getString("acceleration")?.toIntOrNull()) {
+ 1 -> GyroAccel.LINEAR
+ 2 -> GyroAccel.RELAXED
+ 3 -> GyroAccel.AGGRESSIVE
+ else -> GyroAccel.OFF
+ }
+
+ /** H/V Output Mixer from Steam `gyro_vertical_horizontal_ratio` (a vertical:horizontal ratio ×100, def 100 = 1:1)
+ * → our −1..+1 mixer (>0 reduces horizontal, <0 reduces vertical). ponytail: exact scale tuned on-device. */
+ private fun readGyroMixer(s: VdfObject?): Float {
+ val ratio = (s?.getString("gyro_vertical_horizontal_ratio")?.toFloatOrNull() ?: 100f) / 100f
+ return if (ratio >= 1f) (1f - 1f / ratio) else -(1f - ratio)
+ }
+
+ /** Gyro Enable/Suppress/Toggle mode. CONFIRMED from labeled Steam exports (testGyroEnable/Suppress/Toggle, controlled
+ * same-group diff 2026-07-07): the mode is `gyro_button_invert` — **absent = Enable, "0" = Suppress, "2" = Toggle**
+ * (value 1 unobserved). NOTE `gyro_button` is NOT the mode (it reads "1" for all three); the button *chord* is
+ * `gyro_ratchet_button_mask` (→ the gate). ponytail: some base templates also write `gyro_button_invert "0"`, so a
+ * stray "0" could read as Suppress — acceptable (rare, cheap to revisit) and it faithfully imports the labeled set. */
+ private fun readGyroActivation(s: VdfObject?): GyroActivation = when (s?.getString("gyro_button_invert")) {
+ "2" -> GyroActivation.TOGGLE
+ "0" -> GyroActivation.SUPPRESS
+ else -> GyroActivation.ENABLE
+ }
+
+ // ---- per-group tunable settings -> existing model fields (the common ones; the long tail is step 3/7) ----
+
+ /** A percent-scale setting ([key], %/100; def 100 → 1.0). Used for per-axis H/V output scale. */
+ private fun readPctScale(s: VdfObject?, key: String): Float =
+ (s?.getString(key)?.toFloatOrNull() ?: 100f) / 100f
+
+ /** D-pad `layout` (0=8-way / 1=4-way / 2=analog-emu / 3=cross-gate); absent → 8-way. */
+ private fun readDpadLayout(s: VdfObject?): DpadLayout =
+ DpadLayout.fromVdf(s?.getString("layout")?.toIntOrNull() ?: 0)
+
+ /** D-pad `overlap_region` (raw 2000..16000 on the 32768 scale; def 4000) → normalized 0..1 dead-diagonal band. */
+ private fun readOverlap(s: VdfObject?): Float =
+ (s?.getString("overlap_region")?.toIntOrNull() ?: 4000) / 32768f
+
+ /** Response curve from Steam `curve_exponent` (a literal power exponent, commonly 1/2/4 — NOT an enum ordinal)
+ * or `custom_curve_exponent` (25..375, 200≈linear). Since our curves are literal powers (LINEAR=m¹,
+ * AGGRESSIVE=m², WIDE=m³) we map the exponent to the matching power; 4 has no exact curve so it takes the
+ * steepest we model (WIDE). Confirm feel against a capture if a curve seems off; the custom slider is bucketed. */
+ private fun readCurve(s: VdfObject?): ResponseCurve {
+ s?.getString("curve_exponent")?.toIntOrNull()?.let {
+ return when (it) {
+ 1 -> ResponseCurve.LINEAR // m¹
+ 2 -> ResponseCurve.AGGRESSIVE // m²
+ 3 -> ResponseCurve.WIDE // m³
+ 4 -> ResponseCurve.WIDE // steeper than we model; nearest
+ else -> ResponseCurve.LINEAR
+ }
+ }
+ val custom = s?.getString("custom_curve_exponent")?.toIntOrNull() ?: return ResponseCurve.LINEAR
+ return when { // 200 = linear; lower = steeper (aggressive), higher = flatter (relaxed/wide)
+ custom < 150 -> ResponseCurve.AGGRESSIVE
+ custom <= 250 -> ResponseCurve.LINEAR
+ custom <= 320 -> ResponseCurve.RELAXED
+ else -> ResponseCurve.WIDE
+ }
+ }
+
+ /** Steam inner dead zone is raw 0..32768; our `deadzone` is 0..1. Falls back to [default] when unset. */
+ private fun readDeadzone(settings: VdfObject?, default: Float): Float {
+ val raw = settings?.getString("deadzone_inner_radius")?.toIntOrNull()
+ ?: settings?.getString("deadzone")?.toIntOrNull()
+ return raw?.let { (it / 32768f).coerceIn(0f, 0.95f) } ?: default
+ }
+
+ /** `invert_y` is "0"/"1"; keep [default] (our model's convention) when the setting is absent. */
+ private fun readInvertY(settings: VdfObject?, default: Boolean): Boolean =
+ settings?.getString("invert_y")?.let { it.trim() == "1" } ?: default
+
+ /** Menu commit style: `requires_click=1` → click-to-commit only; absent/0 → release-style. */
+ private fun readRequiresClick(settings: VdfObject?): Boolean =
+ settings?.getString("requires_click")?.let { it.trim() == "1" } ?: false
+
+ /** Steam `sensitivity` is a percent (100 = baseline); return a multiplier for our default sensitivities. */
+ private fun readSensScale(settings: VdfObject?): Float {
+ val pct = settings?.getString("sensitivity")?.toFloatOrNull() ?: 100f
+ return (pct / 100f).coerceIn(0.05f, 20f)
+ }
+
+ /** The resolved output of a named input, or [ScOutput.None] if absent/unsupported (for pad d-pad cells). */
+ private fun dirOut(group: VdfObject, name: String): ScOutput = extractInput(group, name)?.output ?: ScOutput.None
+
+ // ---- input / binding / activator parsing --------------------------------------------------------
+
+ /** Resolve a v3 input object (its activators) to a single [Binding]. Repeated same-type activator blocks form a
+ * **macro** (each block = one command, played in sequence); otherwise the best-priority bound activator wins. */
+ private fun resolveInput(inputObj: VdfObject): Binding? {
+ val activators = inputObj.getObject("activators") ?: return null
+ val blocks = activators.objectEntries() // (type, actObj), in order, duplicates preserved
+ if (blocks.isEmpty()) return null
+ // Winning activator type = best priority present (Full_Press beats Long_Press, etc.).
+ val winningType = blocks.map { it.first.lowercase() }.minByOrNull { activatorPriority(it) } ?: return null
+ val winning = blocks.filter { it.first.equals(winningType, ignoreCase = true) }.map { it.second }
+ // Macro: two-or-more blocks of the winning type = a command sequence (within a command, bindings are a chord).
+ if (winning.size >= 2) {
+ val commands = winning.mapNotNull { macroCommandOf(it) }
+ if (commands.size >= 2) return Binding(ScOutput.Macro(commands))
+ }
+ val actObj = winning.first()
+ val bindingStrings = actObj.getObject("bindings")?.getStrings("binding") ?: return null
+ val out0 = parseActivatorOutput(bindingStrings) ?: return null
+ val settings = actObj.getObject("settings")
+ // An action-set switch fires on its activator's edge: a `release` activator => switch on release.
+ val out = if (out0 is ScOutput.SwitchActionSet && winningType == "release") out0.copy(onRelease = true) else out0
+ return Binding(
+ out, activatorOf(winningType, settings),
+ delayStartMs = readDelayMs(settings, "delay_start"), delayEndMs = readDelayMs(settings, "delay_end"),
+ toggle = settings?.getString("toggle") == "1",
+ )
+ }
+
+ /** One macro command: its bindings parsed as individual outputs (pressed together), plus its per-command delays. */
+ private fun macroCommandOf(actObj: VdfObject): MacroCommand? {
+ val outs = actObj.getObject("bindings")?.getStrings("binding")?.mapNotNull { parseBindingOutput(it) }
+ ?.filter { it != ScOutput.None } ?: return null
+ if (outs.isEmpty()) return null
+ val s = actObj.getObject("settings")
+ return MacroCommand(outs, readDelayMs(s, "delay_start"), readDelayMs(s, "delay_end"))
+ }
+
+ private fun readDelayMs(settings: VdfObject?, key: String): Long =
+ settings?.getString(key)?.toLongOrNull()?.coerceIn(0, 5000) ?: 0L
+
+ private class Quad(val prio: Int, val activator: Activator, val output: ScOutput, val type: String)
+
+ /** Look up a named input across both schema variants and resolve it to a [Binding]. */
+ private fun extractInput(group: VdfObject, name: String): Binding? {
+ group.getObject("inputs")?.objectEntries()
+ ?.firstOrNull { it.first.equals(name, ignoreCase = true) }
+ ?.let { return resolveInput(it.second) }
+ group.getObject("bindings")?.stringEntries()
+ ?.firstOrNull { it.first.equals(name, ignoreCase = true) }
+ ?.let { (_, str) -> parseBindingOutput(str)?.let { return Binding(it) } }
+ return null
+ }
+
+ /** First raw binding string for a named input (used to sniff analog trigger passthrough). */
+ private fun rawBindingOf(group: VdfObject, name: String): String? {
+ group.getObject("bindings")?.stringEntries()
+ ?.firstOrNull { it.first.equals(name, ignoreCase = true) }?.let { return it.second }
+ val input = group.getObject("inputs")?.objectEntries()
+ ?.firstOrNull { it.first.equals(name, ignoreCase = true) }?.second ?: return null
+ input.getObject("activators")?.objectValues()?.forEach { act ->
+ act.getObject("bindings")?.getStrings("binding")?.firstOrNull()?.let { return it }
+ }
+ return null
+ }
+
+ /** Lower = higher priority when an input defines multiple activators (we keep one). */
+ private fun activatorPriority(type: String): Int = when (type.lowercase()) {
+ "full_press" -> 0
+ "soft_press" -> 1
+ "start_press" -> 2
+ "double_press" -> 3
+ "long_press" -> 4
+ "release" -> 5
+ else -> 6
+ }
+
+ private fun activatorOf(type: String, settings: VdfObject?): Activator {
+ val holdRepeats = settings?.getString("hold_repeats")?.toIntOrNull() ?: 0
+ if (holdRepeats != 0) return Activator.Turbo(repeatIntervalMs(settings))
+ return when (type.lowercase()) {
+ "double_press" -> Activator.DoublePress(settings?.getString("doubletap_max_duration")?.toLongOrNull() ?: 300)
+ "long_press" -> Activator.LongPress(settings?.getString("long_press_time")?.toLongOrNull() ?: 500)
+ "release" -> Activator.OnRelease // fire on the release edge (was collapsing to Regular)
+ // full_press / start_press / soft_press collapse to Regular (closest digital behaviour).
+ else -> Activator.Regular
+ }
+ }
+
+ /** Steam `repeat_rate` is repeats-per-second; convert to a turbo interval in ms (clamped sane). */
+ private fun repeatIntervalMs(settings: VdfObject?): Long {
+ // Steam's `repeat_rate` is the interval BETWEEN repeats in **milliseconds** (e.g. ToME4 uses 280 and
+ // 1000), NOT a frequency. Treating it as Hz (1000/rate) collapsed every repeat to the 10ms floor —
+ // a machine-gun that made e.g. ToME4's d-pad up fire continuously. Use it directly, clamped to a sane
+ // range (a real key/turbo repeat shouldn't be faster than ~30ms or slower than 2s).
+ val rate = settings?.getString("repeat_rate")?.toLongOrNull() ?: 0
+ return if (rate <= 0) 120 else rate.coerceIn(30, 2000)
+ }
+
+ /** Combine an activator's binding line(s) into one output (multiple key_press lines -> a held combo). */
+ private fun parseActivatorOutput(bindingStrings: List): ScOutput? {
+ val outs = bindingStrings.mapNotNull { parseBindingOutput(it) }
+ if (outs.isEmpty()) return null
+ if (outs.size == 1) return outs[0]
+ return if (outs.all { it is ScOutput.Key }) {
+ ScOutput.Key(outs.flatMap { (it as ScOutput.Key).keys })
+ } else {
+ Timber.tag(TAG).d("mixed multi-binding activator; using first output only")
+ outs[0]
+ }
+ }
+
+ /** Parse a single Steam `binding` string into an [ScOutput], or null if unsupported (e.g. controller_action). */
+ private fun parseBindingOutput(bindingValue: String): ScOutput? {
+ val cmd = bindingValue.substringBefore(',').trim()
+ if (cmd.isEmpty()) return null
+ val t = cmd.split(Regex("\\s+"))
+ return when (t[0].lowercase()) {
+ "key_press" -> keyOf(t.getOrNull(1))?.let { ScOutput.Key(it) }
+ "mouse_button" -> mouseButtonOf(t.getOrNull(1))?.let { ScOutput.MouseButton(it) }
+ "mouse_wheel" -> wheelOf(t.getOrNull(1))?.let { ScOutput.MouseButton(it) }
+ "xinput_button" -> xinputOf(t.getOrNull(1))
+ "mode_shift" -> {
+ // mode_shift -> momentary single-source overlay (decoded in importConfig).
+ val source = t.getOrNull(1)?.lowercase()
+ val groupId = t.getOrNull(2)?.trimEnd(',')
+ if (source != null && groupId != null) ScOutput.ModeShift(source, groupId) else null
+ }
+ "controller_action" -> {
+ // CHANGE_PRESET / add_layer / hold_layer / remove_layer all take a 1-based preset id (id = N-1,
+ // verified against Delf {0,1}->{1,2} and KSP add_layer 7->layer id 6). Edge (press vs release for
+ // CHANGE_PRESET) is finalised in resolveInput.
+ val sub = t.getOrNull(1)?.uppercase()
+ val targetId = t.getOrNull(2)?.trimEnd(',')?.toIntOrNull()?.let { (it - 1).toString() }
+ when (sub) {
+ "CHANGE_PRESET" -> targetId?.let { ScOutput.SwitchActionSet(it) }
+ "ADD_LAYER" -> targetId?.let { ScOutput.LayerOp(it, LayerOpType.ADD) }
+ "HOLD_LAYER" -> targetId?.let { ScOutput.LayerOp(it, LayerOpType.HOLD) }
+ "REMOVE_LAYER" -> targetId?.let { ScOutput.LayerOp(it, LayerOpType.REMOVE) }
+ "SHOW_KEYBOARD" -> ScOutput.ShowKeyboard
+ "MOUSE_DELTA" -> {
+ // controller_action mouse_delta -> a one-shot relative mouse nudge.
+ val dx = t.getOrNull(2)?.trimEnd(',')?.toIntOrNull() ?: 0
+ val dy = t.getOrNull(3)?.trimEnd(',')?.toIntOrNull() ?: 0
+ ScOutput.MouseNudge(dx, dy)
+ }
+ "MOUSE_POSITION" -> {
+ // controller_action MOUSE_POSITION -> warp cursor (x,y in 0..32767 screen space).
+ val x = t.getOrNull(2)?.trimEnd(',')?.toFloatOrNull()
+ val y = t.getOrNull(3)?.trimEnd(',')?.toFloatOrNull()
+ if (x != null && y != null) {
+ ScOutput.MousePosition(x / 32767f, y / 32767f, t.getOrNull(4)?.trimEnd(',') == "1")
+ } else null
+ }
+ else -> { Timber.tag(TAG).d("unsupported controller_action '$cmd'"); null }
+ }
+ }
+ else -> { Timber.tag(TAG).d("unsupported binding '$cmd'"); null }
+ }
+ }
+
+ private fun mouseButtonOf(name: String?): Pointer.Button? = when (name?.uppercase()) {
+ "LEFT" -> Pointer.Button.BUTTON_LEFT
+ "RIGHT" -> Pointer.Button.BUTTON_RIGHT
+ "MIDDLE" -> Pointer.Button.BUTTON_MIDDLE
+ else -> null
+ }
+
+ private fun wheelOf(name: String?): Pointer.Button? = when (name?.uppercase()) {
+ "SCROLL_UP" -> Pointer.Button.BUTTON_SCROLL_UP
+ "SCROLL_DOWN" -> Pointer.Button.BUTTON_SCROLL_DOWN
+ else -> null
+ }
+
+ private fun xinputOf(name: String?): ScOutput? = when (name?.uppercase()) {
+ "A" -> ScOutput.GamepadButton(ExternalController.IDX_BUTTON_A.toInt())
+ "B" -> ScOutput.GamepadButton(ExternalController.IDX_BUTTON_B.toInt())
+ "X" -> ScOutput.GamepadButton(ExternalController.IDX_BUTTON_X.toInt())
+ "Y" -> ScOutput.GamepadButton(ExternalController.IDX_BUTTON_Y.toInt())
+ "SHOULDER_LEFT" -> ScOutput.GamepadButton(ExternalController.IDX_BUTTON_L1.toInt())
+ "SHOULDER_RIGHT" -> ScOutput.GamepadButton(ExternalController.IDX_BUTTON_R1.toInt())
+ "START" -> ScOutput.GamepadButton(ExternalController.IDX_BUTTON_START.toInt())
+ "SELECT", "BACK" -> ScOutput.GamepadButton(ExternalController.IDX_BUTTON_SELECT.toInt())
+ "JOYSTICK_LEFT" -> ScOutput.GamepadButton(ExternalController.IDX_BUTTON_L3.toInt())
+ "JOYSTICK_RIGHT" -> ScOutput.GamepadButton(ExternalController.IDX_BUTTON_R3.toInt())
+ "TRIGGER_LEFT" -> ScOutput.GamepadButton(ExternalController.IDX_BUTTON_L2.toInt())
+ "TRIGGER_RIGHT" -> ScOutput.GamepadButton(ExternalController.IDX_BUTTON_R2.toInt())
+ "DPAD_UP" -> ScOutput.GamepadDpad(0)
+ "DPAD_RIGHT" -> ScOutput.GamepadDpad(1)
+ "DPAD_DOWN" -> ScOutput.GamepadDpad(2)
+ "DPAD_LEFT" -> ScOutput.GamepadDpad(3)
+ else -> { Timber.tag(TAG).d("unsupported xinput_button '$name'"); null }
+ }
+
+ /** Steam key-name -> XKeycode, returned as a single-element list (combos are merged by the caller). */
+ private fun keyOf(name: String?): List? {
+ if (name == null) return null
+ val k = KEY_MAP[name.uppercase()]
+ if (k == null) Timber.tag(TAG).d("unmapped key_press '$name'")
+ return k?.let { listOf(it) }
+ }
+
+ private val KEY_MAP: Map = buildMap {
+ ('A'..'Z').forEach { put(it.toString(), XKeycode.valueOf("KEY_$it")) }
+ ('0'..'9').forEach { put(it.toString(), XKeycode.valueOf("KEY_$it")) }
+ (1..12).forEach { put("F$it", XKeycode.valueOf("KEY_F$it")) }
+ // Numpad — Steam tokens KEYPAD_0..9 (+ operators). Without these, configs that bind the numpad
+ // (e.g. ToME4's 8-way movement radial uses KEYPAD_1/3/7/9 for the diagonals) silently drop those
+ // slots — leaving a 4-way (cardinals-only) radial. Map them to the X11 keypad codes.
+ (0..9).forEach { put("KEYPAD_$it", XKeycode.valueOf("KEY_KP_$it")) }
+ put("KEYPAD_PERIOD", XKeycode.KEY_KP_DEL)
+ put("KEYPAD_DIVIDE", XKeycode.KEY_KP_DIVIDE)
+ put("KEYPAD_FORWARD_SLASH", XKeycode.KEY_KP_DIVIDE)
+ put("KEYPAD_MULTIPLY", XKeycode.KEY_KP_MULTIPLY)
+ put("KEYPAD_MINUS", XKeycode.KEY_KP_SUBTRACT)
+ put("KEYPAD_DASH", XKeycode.KEY_KP_SUBTRACT)
+ put("KEYPAD_PLUS", XKeycode.KEY_KP_ADD)
+ put("SPACE", XKeycode.KEY_SPACE)
+ put("TAB", XKeycode.KEY_TAB)
+ put("ESCAPE", XKeycode.KEY_ESC)
+ put("RETURN", XKeycode.KEY_ENTER)
+ put("ENTER", XKeycode.KEY_ENTER)
+ put("KEYPAD_ENTER", XKeycode.KEY_KP_ENTER)
+ put("LEFT_SHIFT", XKeycode.KEY_SHIFT_L)
+ put("RIGHT_SHIFT", XKeycode.KEY_SHIFT_R)
+ put("LEFT_CONTROL", XKeycode.KEY_CTRL_L)
+ put("LEFT_CTRL", XKeycode.KEY_CTRL_L)
+ put("RIGHT_CONTROL", XKeycode.KEY_CTRL_R)
+ put("RIGHT_CTRL", XKeycode.KEY_CTRL_R)
+ put("LEFT_ALT", XKeycode.KEY_ALT_L)
+ put("RIGHT_ALT", XKeycode.KEY_ALT_R)
+ put("BACKSPACE", XKeycode.KEY_BKSP)
+ put("DELETE", XKeycode.KEY_DEL)
+ put("INSERT", XKeycode.KEY_INSERT)
+ put("HOME", XKeycode.KEY_HOME)
+ put("END", XKeycode.KEY_END)
+ put("PAGE_UP", XKeycode.KEY_PRIOR)
+ put("PAGE_DOWN", XKeycode.KEY_NEXT)
+ put("UP_ARROW", XKeycode.KEY_UP)
+ put("DOWN_ARROW", XKeycode.KEY_DOWN)
+ put("LEFT_ARROW", XKeycode.KEY_LEFT)
+ put("RIGHT_ARROW", XKeycode.KEY_RIGHT)
+ put("CAPSLOCK", XKeycode.KEY_CAPS_LOCK)
+ put("PERIOD", XKeycode.KEY_PERIOD)
+ put("COMMA", XKeycode.KEY_COMMA)
+ put("DASH", XKeycode.KEY_MINUS)
+ put("MINUS", XKeycode.KEY_MINUS)
+ put("EQUALS", XKeycode.KEY_EQUAL)
+ put("FORWARD_SLASH", XKeycode.KEY_SLASH)
+ put("BACK_SLASH", XKeycode.KEY_BACKSLASH)
+ put("SEMICOLON", XKeycode.KEY_SEMICOLON)
+ put("SINGLE_QUOTE", XKeycode.KEY_APOSTROPHE)
+ put("APOSTROPHE", XKeycode.KEY_APOSTROPHE)
+ put("LEFT_BRACKET", XKeycode.KEY_BRACKET_LEFT)
+ put("RIGHT_BRACKET", XKeycode.KEY_BRACKET_RIGHT)
+ put("BACK_QUOTE", XKeycode.KEY_GRAVE)
+ put("TILDE", XKeycode.KEY_GRAVE)
+ }
+}
+
private sealed interface VdfValue
private data class VdfEntry(val key: String, val value: VdfValue)
diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java
index 213f41e306..0a76c8d7a2 100644
--- a/app/src/main/java/com/winlator/winhandler/WinHandler.java
+++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java
@@ -108,6 +108,14 @@ public class WinHandler {
private static final int STANDALONE_PHONE_RUMBLE_DURATION_MS = 70;
private static final int STANDALONE_PHONE_RUMBLE_THROTTLE_MS = 120;
+ /**
+ * Optional Steam-Controller rumble tap. A raw BLE Steam Controller is not an Android input device, so game
+ * rumble would otherwise only buzz the phone. When a TritonMapper session is live it sets this so the game's
+ * XInput rumble is forwarded to the controller's own motors instead. Null (default) = unchanged behavior.
+ */
+ public interface RumbleForwarder { void onRumble(short lowFreq, short highFreq); }
+ public static volatile RumbleForwarder scRumbleForwarder = null;
+
// Add method to set InputControlsView
public void setInputControlsView(InputControlsView view) {
this.inputControlsView = view;
@@ -818,6 +826,14 @@ private void startVibration(int slot, short lowFreq, short highFreq) {
if (slot < 0 || slot >= MAX_PLAYERS) {
return;
}
+ // A live Steam Controller owns rumble output (its own motors); forward and skip the per-slot device path.
+ RumbleForwarder fwd = scRumbleForwarder;
+ if (fwd != null) {
+ // Cancel any local buzz still in flight on this slot so it doesn't linger alongside the SC's rumble.
+ if (isRumbling[slot]) { stopDeviceVibration(rumbleDeviceIds[slot]); isRumbling[slot] = false; }
+ fwd.onRumble(lowFreq, highFreq);
+ return;
+ }
if (startDeviceVibration(rumbleDeviceIds[slot], lowFreq, highFreq)) {
isRumbling[slot] = true;
}
@@ -893,6 +909,9 @@ private void stopVibration(int slot) {
if (slot < 0 || slot >= MAX_PLAYERS) {
return;
}
+ // A live Steam Controller owns rumble output; forward the stop and skip the per-slot device path.
+ RumbleForwarder fwd = scRumbleForwarder;
+ if (fwd != null) { fwd.onRumble((short) 0, (short) 0); return; }
if (!isRumbling[slot]) return;
stopDeviceVibration(rumbleDeviceIds[slot]);
isRumbling[slot] = false;
diff --git a/app/src/main/java/com/winlator/xserver/InputDeviceManager.java b/app/src/main/java/com/winlator/xserver/InputDeviceManager.java
index 0a7143b7b0..bcdc4e55f9 100644
--- a/app/src/main/java/com/winlator/xserver/InputDeviceManager.java
+++ b/app/src/main/java/com/winlator/xserver/InputDeviceManager.java
@@ -38,6 +38,20 @@ public void run() {
}
};
+ /**
+ * Called when the guest process is suspended (overlay/editor pause SIGSTOPs it). A suspended guest stops draining
+ * its X socket, so the main-thread key auto-repeat ({@link #autoRepeatRunnable}) must be stopped here — otherwise
+ * its next {@code onKeyPress} -> {@code Window.sendEvent} -> blocking {@code ClientSocket.write} to the
+ * non-draining guest hangs the UI thread until an ANR. Also forgets currently-held keys so the runnable can't
+ * re-arm itself (it re-posts while the key stays in {@code keyboard.getPressedKeys()}). Pure local state — sends
+ * no X events. Call BEFORE suspending the guest.
+ */
+ public void onGuestSuspended() {
+ autoRepeatHandler.removeCallbacks(autoRepeatRunnable);
+ currentRepeatingKeycode = 0;
+ xServer.keyboard.getPressedKeys().clear();
+ }
+
public InputDeviceManager(XServer xServer) {
this.xServer = xServer;
pointWindow = xServer.windowManager.rootWindow;
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 955d1b1e60..7026c819f7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -247,6 +247,12 @@
On-screen Controller
Edit On-screen Controller
Edit Physical Controller
+ Steam Controller
+ Buttons & Bindings
+ Menu Labels
+ Overlay Layout
+ Keyboard Layout
+ Manage Configs
Disconnected
Reset On-Screen Controls
Open Navigation Menu
diff --git a/app/src/test/java/app/gamenative/steamcontroller/ActionLayersTest.kt b/app/src/test/java/app/gamenative/steamcontroller/ActionLayersTest.kt
new file mode 100644
index 0000000000..06a5fa9a63
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/ActionLayersTest.kt
@@ -0,0 +1,61 @@
+package app.gamenative.steamcontroller
+
+import app.gamenative.utils.SteamControllerProfileImporter
+import com.winlator.xserver.XKeycode
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * Action-layer overlays (build step 3): `add_layer`/`hold_layer`/`remove_layer` bindings push/pop a partial
+ * overlay ([mergeProfiles]) over the active set. The synthetic config's Overlay layer rebinds only the face
+ * buttons (A: Q -> M), so the test proves the per-source merge and the push/pop edge handling end to end.
+ */
+@RunWith(RobolectricTestRunner::class)
+class ActionLayersTest {
+
+ private fun state(buttons: Int): TritonState = TritonState().apply { this.buttons = buttons }
+
+ private val a = TritonProtocol.BTN_A
+ private val rb = TritonProtocol.BTN_RBUMPER // hold_layer
+ private val lb = TritonProtocol.BTN_LBUMPER // add_layer
+ private val menu = TritonProtocol.BTN_VIEW // button_menu -> remove_layer
+
+ private fun newInterp(sink: RecordingSink): ProfileInterpreter {
+ val cfg = SteamControllerProfileImporter.importConfig(load("actionlayers_v3.vdf"))
+ return ProfileInterpreter(sink, cfg.defaultProfile(), haptics = null).also { it.setConfig(cfg) }
+ }
+
+ @Test
+ fun `hold_layer overlays the face buttons while held and pops on release`() {
+ val sink = RecordingSink()
+ val interp = newInterp(sink)
+
+ interp.apply(state(0))
+ interp.apply(state(a)); interp.apply(state(0)) // base set: A -> Q
+ interp.apply(state(rb)) // hold right bumper -> push Overlay
+ interp.apply(state(rb or a)) // overlay: A -> M
+ interp.apply(state(rb)) // release A
+ interp.apply(state(0)) // release bumper -> pop Overlay
+ interp.apply(state(a)) // base again: A -> Q
+
+ assertEquals(2, sink.keyPresses(XKeycode.KEY_Q))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_M))
+ }
+
+ @Test
+ fun `add_layer then remove_layer pushes and pops a persistent overlay`() {
+ val sink = RecordingSink()
+ val interp = newInterp(sink)
+
+ interp.apply(state(0))
+ interp.apply(state(lb)); interp.apply(state(0)) // add_layer -> Overlay stays up
+ interp.apply(state(a)); interp.apply(state(0)) // overlay: A -> M
+ interp.apply(state(menu)); interp.apply(state(0)) // remove_layer -> pop
+ interp.apply(state(a)) // base: A -> Q
+
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_M))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_Q))
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/ActionSetsTest.kt b/app/src/test/java/app/gamenative/steamcontroller/ActionSetsTest.kt
new file mode 100644
index 0000000000..24494e96b6
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/ActionSetsTest.kt
@@ -0,0 +1,58 @@
+package app.gamenative.steamcontroller
+
+import app.gamenative.utils.SteamControllerProfileImporter
+import com.winlator.xserver.XKeycode
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * End-to-end test of config-driven action-set switching (build step 3): the importer turns a 2-set config into
+ * an [ScConfig], and [ProfileInterpreter] swaps the live profile when a `CHANGE_PRESET`/[ScOutput.SwitchActionSet]
+ * binding fires — reproducing the real "hold for menus" round trip (enter on press in set A, leave on release in
+ * set B). Driven by synthetic button states, fully headless.
+ */
+@RunWith(RobolectricTestRunner::class)
+class ActionSetsTest {
+
+ private fun state(buttons: Int): TritonState = TritonState().apply { this.buttons = buttons }
+
+ @Test
+ fun `hold-for-menus round trip swaps the active set`() {
+ val cfg = SteamControllerProfileImporter.importConfig(load("actionsets_v3.vdf"))
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, cfg.defaultProfile(), haptics = null)
+ interp.setConfig(cfg)
+
+ val a = TritonProtocol.BTN_A
+ val bumper = TritonProtocol.BTN_RBUMPER
+
+ interp.apply(state(0)) // baseline
+ interp.apply(state(a)) // Default set: A -> Q
+ interp.apply(state(0))
+ assertEquals("0", interp.activeSetId)
+
+ interp.apply(state(bumper)) // hold bumper -> enter Menus set (id 1)
+ assertEquals("1", interp.activeSetId)
+ interp.apply(state(bumper or a)) // Menus set: A -> M (not Q)
+ interp.apply(state(bumper)) // release A
+ interp.apply(state(0)) // release bumper -> Menus' release binding returns to Default
+ assertEquals("0", interp.activeSetId)
+ interp.apply(state(a)) // Default again: A -> Q
+
+ assertEquals("Q fired in the Default set, before and after the menu trip", 2, sink.keyPresses(XKeycode.KEY_Q))
+ assertEquals("M fired only while the Menus set was active", 1, sink.keyPresses(XKeycode.KEY_M))
+ }
+
+ @Test
+ fun `with no config installed SwitchActionSet bindings are inert`() {
+ // The Default set alone (no ScConfig) must not crash or emit anything for the switch binding.
+ val cfg = SteamControllerProfileImporter.importConfig(load("actionsets_v3.vdf"))
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, cfg.sets.getValue("0"), haptics = null)
+ interp.apply(state(TritonProtocol.BTN_RBUMPER))
+ interp.apply(state(0))
+ assertEquals(0, sink.keys.size) // switch binding produced no key/mouse output
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/ActivatorsTest.kt b/app/src/test/java/app/gamenative/steamcontroller/ActivatorsTest.kt
new file mode 100644
index 0000000000..7ffbf924fc
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/ActivatorsTest.kt
@@ -0,0 +1,72 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.xserver.XKeycode
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/** Activator timing state machines, tested deterministically with a virtual clock. */
+@RunWith(RobolectricTestRunner::class)
+class ActivatorsTest {
+
+ private val A = TritonProtocol.BTN_A
+ private fun press() = TritonState().apply { buttons = A }
+ private fun release() = TritonState()
+
+ private class Harness(activator: Activator, val key: XKeycode = XKeycode.KEY_F1) {
+ var t = 0L
+ val sink = RecordingSink()
+ private val interp = ProfileInterpreter(
+ sink,
+ ScProfile(buttons = mapOf(TritonProtocol.BTN_A to Binding(ScOutput.Key(key), activator))),
+ haptics = null,
+ clock = { t },
+ )
+ fun at(time: Long, s: TritonState) { t = time; interp.apply(s) }
+ fun downs() = sink.keyPresses(key)
+ fun ups() = sink.keys.count { it.key == key && !it.pressed }
+ }
+
+ @Test
+ fun `double press fires only on the second press within the window`() {
+ val h = Harness(Activator.DoublePress(windowMs = 300))
+ h.at(0, press()); h.at(10, release())
+ assertEquals("single press should not fire", 0, h.downs())
+ h.at(100, press()) // second press within 300ms -> fire
+ assertEquals(1, h.downs())
+ h.at(110, release())
+ }
+
+ @Test
+ fun `double press does not fire when presses are too far apart`() {
+ val h = Harness(Activator.DoublePress(windowMs = 300))
+ h.at(0, press()); h.at(10, release())
+ h.at(1000, press()) // > window since first -> treated as a new first press
+ assertEquals(0, h.downs())
+ h.at(1010, release())
+ }
+
+ @Test
+ fun `long press fires after the hold threshold and releases on lift`() {
+ val h = Harness(Activator.LongPress(holdMs = 500), key = XKeycode.KEY_F2)
+ h.at(0, press())
+ h.at(200, press()) // still held, below threshold
+ assertEquals(0, h.downs())
+ h.at(500, press()) // threshold reached -> press
+ assertEquals(1, h.downs())
+ h.at(600, release()) // physical release -> output up
+ assertEquals(1, h.ups())
+ }
+
+ @Test
+ fun `turbo pulses on press and every interval while held`() {
+ val h = Harness(Activator.Turbo(intervalMs = 80), key = XKeycode.KEY_F3)
+ h.at(0, press()) // pulse 1 (on press)
+ h.at(80, press()) // pulse 2
+ h.at(160, press()) // pulse 3
+ h.at(200, release())
+ assertEquals(3, h.downs())
+ assertEquals(3, h.ups()) // each pulse is press+release
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/AdvancedModesTest.kt b/app/src/test/java/app/gamenative/steamcontroller/AdvancedModesTest.kt
new file mode 100644
index 0000000000..5e86d9f8d1
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/AdvancedModesTest.kt
@@ -0,0 +1,255 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.xserver.XKeycode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/** Unit tests for the step-7 stick modes (mouse / flickstick / response curve) and the OnRelease activator. */
+@RunWith(RobolectricTestRunner::class)
+class AdvancedModesTest {
+
+ private fun stickState(lx: Int = 0, ly: Int = 0) = TritonState().apply { leftStickX = lx; leftStickY = ly }
+
+ @Test
+ fun `gyro pad-touch gate aims only while the pad is touched`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(gyro = GyroMode.Mouse(sensitivity = 5f, gate = GyroGate.RIGHT_PAD_TOUCH)), haptics = null)
+ // Gyro rotating but pad NOT touched -> gated off, no aim.
+ interp.apply(TritonState().apply { gyroZ = 500; gyroX = 200 })
+ assertEquals(0, sink.mouseMoves)
+ // Same rotation while the right pad is touched -> aim fires.
+ interp.apply(TritonState().apply { gyroZ = 500; gyroX = 200; buttons = TritonProtocol.BTN_RPAD_TOUCH })
+ assertTrue("gyro aims while pad touched", sink.mouseMoves > 0)
+ }
+
+ @Test
+ fun `gyro joystick mode deflects the output stick`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(gyro = GyroMode.Joystick(Stick.RIGHT, sensitivity = 0.001f, gate = GyroGate.ALWAYS)), haptics = null)
+ interp.apply(TritonState().apply { gyroZ = 500 }) // yaw -> right stick X; natural default negates -> -0.5
+ assertTrue("right stick X deflected by gyro (natural sign)", sink.minThumbRX < -0.4f)
+ }
+
+ @Test
+ fun `gyro joystick power curve and output range shape the deflection`() {
+ // Aggressive curve (0.5) raises a mid-range input; capped output max reduces the peak.
+ fun peak(mode: GyroMode.Joystick): Float {
+ val sink = RecordingSink()
+ ProfileInterpreter(sink, ScProfile(gyro = mode), haptics = null).apply(TritonState().apply { gyroZ = 4000 })
+ return kotlin.math.abs(sink.minThumbRX)
+ }
+ val linear = GyroMode.Joystick(Stick.RIGHT, sensitivity = 0.0001f, gate = GyroGate.ALWAYS) // small -> mid deflection
+ val relaxed = peak(linear.copy(powerCurve = 4f)) // 4 = relaxed -> smaller at mid input
+ val lin = peak(linear)
+ assertTrue("relaxed curve reduces mid-range deflection", relaxed < lin)
+ // Output max caps the magnitude.
+ val capped = peak(GyroMode.Joystick(Stick.RIGHT, sensitivity = 1f, gate = GyroGate.ALWAYS, outputMax = 0.5f))
+ assertTrue("output max caps deflection at ~0.5", capped <= 0.51f)
+ }
+
+ @Test
+ fun `gyro camera style returns to center when rotation stops`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(gyro = GyroMode.Joystick(Stick.RIGHT, sensitivity = 0.001f, gate = GyroGate.ALWAYS, deflection = false)), haptics = null)
+ interp.apply(TritonState().apply { gyroZ = 500 })
+ assertTrue("camera deflects while rotating", sink.lastThumbRX < -0.4f)
+ interp.apply(TritonState().apply { gyroZ = 0 }) // rate-based: no rotation -> center
+ assertEquals(0f, sink.lastThumbRX, 0.001f)
+ }
+
+ @Test
+ fun `gyro deflection style holds position at rest and resets when the gate closes`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(gyro = GyroMode.Joystick(Stick.RIGHT, sensitivity = 0.001f, gate = GyroGate.RIGHT_GRIP, deflection = true)), haptics = null)
+ interp.apply(TritonState().apply { gyroZ = 500; buttons = TritonProtocol.BTN_RGRIP }) // integrate -> -0.5
+ val held = sink.lastThumbRX
+ assertTrue("deflection accumulates an angle", held < -0.4f)
+ interp.apply(TritonState().apply { gyroZ = 0; buttons = TritonProtocol.BTN_RGRIP }) // no rotation -> HOLD
+ assertEquals("holds position while gated open", held, sink.lastThumbRX, 0.001f)
+ interp.apply(TritonState().apply { gyroZ = 0 }) // gate released -> ratchet reset to center
+ assertEquals(0f, sink.lastThumbRX, 0.001f)
+ }
+
+ @Test
+ fun `gyro toggle activation flips aim on each grip press edge`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(gyro = GyroMode.Mouse(sensitivity = 5f, gate = GyroGate.RIGHT_GRIP, activation = GyroActivation.TOGGLE)), haptics = null)
+ val grip = TritonState().apply { gyroZ = 500; buttons = TritonProtocol.BTN_RGRIP }
+ val noGrip = TritonState().apply { gyroZ = 500 }
+ interp.apply(grip) // press edge -> toggle ON -> aims
+ val a = sink.mouseMoves; assertTrue("toggle on aims", a > 0)
+ interp.apply(noGrip) // released but still ON -> keeps aiming (hands-free)
+ val b = sink.mouseMoves; assertTrue("stays on after release", b > a)
+ interp.apply(grip) // press edge -> toggle OFF
+ interp.apply(noGrip)
+ assertEquals("no aim after toggle off", b, sink.mouseMoves)
+ }
+
+ private fun gyroDx(mode: GyroMode.Mouse, gz: Int, gx: Int = 0): Long {
+ val sink = RecordingSink()
+ ProfileInterpreter(sink, ScProfile(gyro = mode), haptics = null).apply(TritonState().apply { gyroZ = gz; gyroX = gx })
+ return sink.mouseDx
+ }
+
+ @Test
+ fun `gyro mouse H-V mixer rebalances the horizontal axis`() {
+ val base = GyroMode.Mouse(sensitivity = 1f, gate = GyroGate.ALWAYS)
+ val full = kotlin.math.abs(gyroDx(base, 1000))
+ val mixed = kotlin.math.abs(gyroDx(base.copy(hvMixer = 0.5f), 1000)) // >0 reduces horizontal
+ assertEquals("horizontal halved by +0.5 mixer", full / 2, mixed)
+ }
+
+ @Test
+ fun `gyro speed deadzone and precision scale slow rotation`() {
+ val base = GyroMode.Mouse(sensitivity = 1f, gate = GyroGate.ALWAYS)
+ // Speed deadzone: below the threshold speed -> no output.
+ assertEquals(0L, gyroDx(base.copy(speedDeadzone = 2000f), 1000))
+ assertTrue("above deadzone aims", kotlin.math.abs(gyroDx(base.copy(speedDeadzone = 2000f), 3000)) > 0)
+ // Precision: below precisionSpeed, sensitivity scales down proportionally (1000/2000 = half).
+ assertEquals(kotlin.math.abs(gyroDx(base, 1000)) / 2, kotlin.math.abs(gyroDx(base.copy(precisionSpeed = 2000f), 1000)))
+ }
+
+ @Test
+ fun `gyro acceleration increases sensitivity at high speed`() {
+ val base = GyroMode.Mouse(sensitivity = 1f, gate = GyroGate.ALWAYS)
+ val off = kotlin.math.abs(gyroDx(base, 8000))
+ val agg = kotlin.math.abs(gyroDx(base.copy(accel = GyroAccel.AGGRESSIVE), 8000))
+ assertTrue("aggressive accel turns further at speed", agg > off)
+ }
+
+ @Test
+ fun `gyro gate can be any button (not just grips)`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(gyro = GyroMode.Mouse(sensitivity = 5f, gate = GyroGate.R4)), haptics = null)
+ interp.apply(TritonState().apply { gyroZ = 500 }) // R4 not held -> no aim
+ assertEquals(0, sink.mouseMoves)
+ interp.apply(TritonState().apply { gyroZ = 500; buttons = TritonProtocol.BTN_R4 }) // R4 held -> aims
+ assertTrue("gyro aims while R4 paddle held", sink.mouseMoves > 0)
+ }
+
+ @Test
+ fun `gyro suppress activation aims only while the grip is NOT held`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(gyro = GyroMode.Mouse(sensitivity = 5f, gate = GyroGate.RIGHT_GRIP, activation = GyroActivation.SUPPRESS)), haptics = null)
+ interp.apply(TritonState().apply { gyroZ = 500 }) // grip not held -> active
+ val a = sink.mouseMoves; assertTrue("suppress: aims when released", a > 0)
+ interp.apply(TritonState().apply { gyroZ = 500; buttons = TritonProtocol.BTN_RGRIP }) // held -> suppressed
+ assertEquals("suppress: no aim while held", a, sink.mouseMoves)
+ }
+
+ @Test
+ fun `gyro mouse uses the natural direction and per-axis invert`() {
+ val nat = RecordingSink()
+ ProfileInterpreter(nat, ScProfile(gyro = GyroMode.Mouse(sensitivity = 5f, gate = GyroGate.ALWAYS)), haptics = null)
+ .apply(TritonState().apply { gyroZ = 500 }) // yaw-right -> aim-right = NEGATIVE raw sign (natural)
+ assertTrue("natural yaw -> dx < 0", nat.mouseDx < 0)
+ val inv = RecordingSink()
+ ProfileInterpreter(inv, ScProfile(gyro = GyroMode.Mouse(sensitivity = 5f, gate = GyroGate.ALWAYS, invertX = true)), haptics = null)
+ .apply(TritonState().apply { gyroZ = 500 })
+ assertTrue("invertX flips it back to dx > 0", inv.mouseDx > 0)
+ }
+
+ @Test
+ fun `gyro stick-touch gate aims only while the stick is touched`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(gyro = GyroMode.Mouse(sensitivity = 5f, gate = GyroGate.LEFT_STICK_TOUCH)), haptics = null)
+ interp.apply(TritonState().apply { gyroZ = 500 })
+ assertEquals(0, sink.mouseMoves)
+ interp.apply(TritonState().apply { gyroZ = 500; buttons = TritonProtocol.BTN_LSTICK_TOUCH })
+ assertTrue("gyro aims while stick touched", sink.mouseMoves > 0)
+ }
+
+ @Test
+ fun `gyro any-touch gate aims when any surface is touched`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(gyro = GyroMode.Mouse(sensitivity = 5f, gate = GyroGate.ANY_TOUCH)), haptics = null)
+ interp.apply(TritonState().apply { gyroZ = 500 })
+ assertEquals("no aim when nothing touched", 0, sink.mouseMoves)
+ interp.apply(TritonState().apply { gyroZ = 500; buttons = TritonProtocol.BTN_RSTICK_TOUCH })
+ assertTrue("aims on stick touch", sink.mouseMoves > 0)
+ val n = sink.mouseMoves
+ interp.apply(TritonState().apply { gyroZ = 500; buttons = TritonProtocol.BTN_LPAD_TOUCH })
+ assertTrue("aims on pad touch too", sink.mouseMoves > n)
+ }
+
+ @Test
+ fun `gyro all-touch gate aims only when every surface is touched`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(gyro = GyroMode.Mouse(sensitivity = 5f, gate = GyroGate.ALL_TOUCH)), haptics = null)
+ val three = TritonProtocol.BTN_LPAD_TOUCH or TritonProtocol.BTN_RPAD_TOUCH or TritonProtocol.BTN_LSTICK_TOUCH
+ interp.apply(TritonState().apply { gyroZ = 500; buttons = three })
+ assertEquals("3 of 4 touched -> still gated off", 0, sink.mouseMoves)
+ interp.apply(TritonState().apply { gyroZ = 500; buttons = three or TritonProtocol.BTN_RSTICK_TOUCH })
+ assertTrue("all 4 touched -> aims", sink.mouseMoves > 0)
+ }
+
+ @Test
+ fun `pad mouse-joystick moves the cursor by displacement from center`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(leftPad = PadMode.MouseJoystick(sensitivity = 20f, deadzone = 0.1f)), haptics = null)
+ // finger at center while touched -> no motion (self-centering)
+ interp.apply(TritonState().apply { buttons = TritonProtocol.BTN_LPAD_TOUCH; leftPadX = 0; leftPadY = 0 })
+ assertEquals(0, sink.mouseMoves)
+ // finger held far right -> cursor glides right, keeps moving each frame while held
+ interp.apply(TritonState().apply { buttons = TritonProtocol.BTN_LPAD_TOUCH; leftPadX = 32000; leftPadY = 0 })
+ assertTrue("cursor moved right", sink.mouseDx > 0)
+ // lift -> stops
+ val before = sink.mouseMoves
+ interp.apply(TritonState())
+ assertEquals("no motion after lift", before, sink.mouseMoves)
+ }
+
+ @Test
+ fun `joystick_mouse drives the pointer from stick deflection`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(leftStick = StickMode.Mouse(sensitivity = 10f, deadzone = 0.1f)), haptics = null)
+ interp.apply(stickState(lx = 32767, ly = 0)) // full right
+ assertTrue("cursor moved right", sink.mouseDx > 0)
+ assertEquals("no vertical for pure-X", 0L, sink.mouseDy)
+ }
+
+ @Test
+ fun `joystick_mouse respects the deadzone`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(leftStick = StickMode.Mouse(sensitivity = 10f, deadzone = 0.5f)), haptics = null)
+ interp.apply(stickState(lx = 3000, ly = 0)) // ~0.09, inside the 0.5 deadzone
+ assertEquals(0, sink.mouseMoves)
+ }
+
+ @Test
+ fun `flickstick maps horizontal deflection to yaw only`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(leftStick = StickMode.FlickStick(sensitivity = 20f, deadzone = 0.2f)), haptics = null)
+ interp.apply(stickState(lx = 32767, ly = 32767)) // pushed up-right; flickstick uses X only
+ assertTrue(sink.mouseDx > 0)
+ assertEquals(0L, sink.mouseDy)
+ }
+
+ @Test
+ fun `aggressive response curve attenuates mid deflection vs linear`() {
+ val lin = RecordingSink()
+ ProfileInterpreter(lin, ScProfile(leftStick = StickMode.JoystickMove(Stick.LEFT, invertY = false, curve = ResponseCurve.LINEAR)), haptics = null)
+ .apply(stickState(lx = 16000)) // ~half deflection
+ val agg = RecordingSink()
+ ProfileInterpreter(agg, ScProfile(leftStick = StickMode.JoystickMove(Stick.LEFT, invertY = false, curve = ResponseCurve.AGGRESSIVE)), haptics = null)
+ .apply(stickState(lx = 16000))
+ assertTrue("aggressive curve < linear at mid", agg.maxThumbLX in 0f..lin.maxThumbLX && agg.maxThumbLX < lin.maxThumbLX)
+ }
+
+ private fun buttonA(down: Boolean) = TritonState().apply { buttons = if (down) TritonProtocol.BTN_A else 0 }
+
+ @Test
+ fun `OnRelease activator fires on the release edge, not on press`() {
+ val sink = RecordingSink()
+ val profile = ScProfile(buttons = mapOf(TritonProtocol.BTN_A to Binding(ScOutput.Key(XKeycode.KEY_F1), Activator.OnRelease)))
+ val interp = ProfileInterpreter(sink, profile, haptics = null)
+ interp.apply(buttonA(true)) // press -> nothing yet
+ assertEquals(0, sink.keyPresses(XKeycode.KEY_F1))
+ interp.apply(buttonA(false)) // release -> pulse
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F1))
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_F1 && !it.pressed })
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/EditableConfigTest.kt b/app/src/test/java/app/gamenative/steamcontroller/EditableConfigTest.kt
new file mode 100644
index 0000000000..61ac3a5e86
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/EditableConfigTest.kt
@@ -0,0 +1,127 @@
+package app.gamenative.steamcontroller
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/** Phase 5d multi-action-set editor model: [ScEditableConfig] ↔ runtime [ScConfig], switch-set binds, migration. */
+@RunWith(RobolectricTestRunner::class)
+class EditableConfigTest {
+
+ @Test
+ fun `switch-action-set binding round-trips through EditBinding`() {
+ val eb = EditBinding(kind = OutputKind.SWITCH_ACTION_SET, targetSetId = "2")
+ val out = eb.toOutput()
+ assertTrue(out is ScOutput.SwitchActionSet)
+ assertEquals("2", (out as ScOutput.SwitchActionSet).targetSetId)
+ // and back
+ val back = EditBinding.fromOutput(out)
+ assertEquals(OutputKind.SWITCH_ACTION_SET, back.kind)
+ assertEquals("2", back.targetSetId)
+ }
+
+ @Test
+ fun `blank target set id yields no output (None)`() {
+ assertTrue(EditBinding(OutputKind.SWITCH_ACTION_SET, targetSetId = "").toOutput() is ScOutput.None)
+ }
+
+ @Test
+ fun `toScConfig maps every set by id with the chosen default`() {
+ val cfg = ScEditableConfig(
+ sets = listOf(
+ ScEditableSet(id = "0", name = "Default"),
+ ScEditableSet(
+ id = "1", name = "Menu",
+ profile = ScEditableProfile(
+ buttons = mapOf("RIGHT_BUMPER" to EditBinding(OutputKind.SWITCH_ACTION_SET, targetSetId = "0")),
+ ),
+ ),
+ ),
+ defaultSetId = "1",
+ )
+ val rc = cfg.toScConfig()
+ assertEquals(setOf("0", "1"), rc.sets.keys)
+ assertEquals("1", rc.defaultSetId)
+ // The set-1 right-bumper bind became a runtime SwitchActionSet to set 0.
+ val out = rc.sets["1"]!!.buttons[TritonProtocol.BTN_RBUMPER]?.output
+ assertTrue(out is ScOutput.SwitchActionSet)
+ assertEquals("0", (out as ScOutput.SwitchActionSet).targetSetId)
+ }
+
+ @Test
+ fun `defaultSetId falls back to first set when the id is unknown`() {
+ val cfg = ScEditableConfig(sets = listOf(ScEditableSet(id = "a"), ScEditableSet(id = "b")), defaultSetId = "zzz")
+ assertEquals("a", cfg.toScConfig().defaultSetId)
+ }
+
+ @Test
+ fun `fromSingle wraps a legacy profile as one set keyed 0`() {
+ val p = ScEditableProfile(name = "Legacy")
+ val cfg = ScEditableConfig.fromSingle(p)
+ assertEquals(1, cfg.sets.size)
+ assertEquals("0", cfg.sets[0].id)
+ assertEquals("0", cfg.defaultSetId)
+ assertEquals("Legacy", cfg.sets[0].name)
+ }
+
+ @Test
+ fun `layer ops and controller actions round-trip through EditBinding`() {
+ // Hold-layer
+ val hold = EditBinding(OutputKind.LAYER_OP, layerId = "L", layerOp = "HOLD").toOutput()
+ assertTrue(hold is ScOutput.LayerOp)
+ assertEquals("L", (hold as ScOutput.LayerOp).layerId)
+ assertEquals(LayerOpType.HOLD, hold.op)
+ assertEquals(OutputKind.LAYER_OP, EditBinding.fromOutput(hold).kind)
+ assertEquals("L", EditBinding.fromOutput(hold).layerId)
+ // Show keyboard / open quick menu
+ assertTrue(EditBinding(OutputKind.SHOW_KEYBOARD).toOutput() is ScOutput.ShowKeyboard)
+ assertTrue(EditBinding(OutputKind.OPEN_QUICK_MENU).toOutput() is ScOutput.OpenQuickMenu)
+ assertEquals(OutputKind.SHOW_KEYBOARD, EditBinding.fromOutput(ScOutput.ShowKeyboard).kind)
+ assertEquals(OutputKind.OPEN_QUICK_MENU, EditBinding.fromOutput(ScOutput.OpenQuickMenu).kind)
+ // A blank layer target yields no output.
+ assertTrue(EditBinding(OutputKind.LAYER_OP, layerId = "").toOutput() is ScOutput.None)
+ }
+
+ @Test
+ fun `an authored layer derives its setSources and a hold-layer bind drives the merge`() {
+ // Base set: hold Left Bumper -> push the "1" layer. Layer "1": rebinds A -> key 9 AND overrides the right pad.
+ val cfg = ScEditableConfig(
+ sets = listOf(
+ ScEditableSet(
+ id = "0", name = "Base",
+ profile = ScEditableProfile(buttons = mapOf("LEFT_BUMPER" to EditBinding(OutputKind.LAYER_OP, layerId = "1", layerOp = "HOLD"))),
+ ),
+ ScEditableSet(
+ id = "1", name = "Overlay", isLayer = true,
+ profile = ScEditableProfile(
+ buttons = mapOf("A" to EditBinding(OutputKind.KEY, keys = listOf("KEY_9"))),
+ rightPad = EditAnalog(AnalogMode.SCROLL_WHEEL), // an analog override -> right_trackpad source
+ ),
+ ),
+ ),
+ defaultSetId = "0",
+ )
+ val rc = cfg.toScConfig()
+ // The layer's derived sources include the right pad it overrides (buttons always merge, so they're not listed).
+ assertEquals(setOf("right_trackpad"), rc.setSources["1"])
+ // Base has no source list (it's switched-to, not merged).
+ assertTrue(rc.setSources["0"] == null)
+ // The hold-layer bind is a runtime LayerOp(HOLD) on the bumper.
+ val out = rc.sets["0"]!!.buttons[TritonProtocol.BTN_LBUMPER]?.output
+ assertTrue(out is ScOutput.LayerOp && (out as ScOutput.LayerOp).op == LayerOpType.HOLD && out.layerId == "1")
+ // mergeProfiles applies the layer: A -> key 9 (button merge) and the right pad becomes a scroll wheel.
+ val merged = mergeProfiles(rc.sets["0"]!!, rc.sets["1"]!!, rc.setSources["1"]!!)
+ assertEquals(listOf(com.winlator.xserver.XKeycode.KEY_9), (merged.buttons[TritonProtocol.BTN_A]!!.output as ScOutput.Key).keys)
+ assertTrue(merged.rightPad is PadMode.ScrollWheel)
+ }
+
+ @Test
+ fun `nextSetId skips ids already in use`() {
+ val cfg = ScEditableConfig(sets = listOf(ScEditableSet(id = "0"), ScEditableSet(id = "1")))
+ assertEquals("2", cfg.nextSetId())
+ val gap = ScEditableConfig(sets = listOf(ScEditableSet(id = "0"), ScEditableSet(id = "2")))
+ assertEquals("1", gap.nextSetId())
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/HapticsRumbleTest.kt b/app/src/test/java/app/gamenative/steamcontroller/HapticsRumbleTest.kt
new file mode 100644
index 0000000000..6de3775d2c
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/HapticsRumbleTest.kt
@@ -0,0 +1,73 @@
+package app.gamenative.steamcontroller
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/** Byte layout of the Triton rumble output report (SDL MsgHapticRumble, id 0x80, packed LE). */
+class HapticsRumbleTest {
+ @Test fun rumbleReport_packsMotorsLittleEndian() {
+ val r = TritonHaptics.rumbleReport(0x1234, 0xABCD)
+ assertEquals("10-byte report", 10, r.size)
+ assertArrayEquals(
+ byteArrayOf(
+ 0x80.toByte(), // id
+ 0, // type
+ 0, 0, // intensity
+ 0x34, 0x12, // left.speed (low-freq motor), LE
+ 0, // left.gain
+ 0xCD.toByte(), 0xAB.toByte(), // right.speed (high-freq motor), LE
+ 0, // right.gain
+ ),
+ r,
+ )
+ }
+
+ @Test fun rumbleReport_zeroIsAllZeroExceptId() {
+ val r = TritonHaptics.rumbleReport(0, 0)
+ assertEquals(0x80.toByte(), r[0])
+ for (i in 1 until r.size) assertEquals("byte $i", 0.toByte(), r[i])
+ }
+
+ @Test fun rumbleReport_fullScaleClampsToTwoBytes() {
+ val r = TritonHaptics.rumbleReport(0xFFFF, 0xFFFF)
+ assertArrayEquals(byteArrayOf(0xFF.toByte(), 0xFF.toByte()), r.copyOfRange(4, 6))
+ assertArrayEquals(byteArrayOf(0xFF.toByte(), 0xFF.toByte()), r.copyOfRange(7, 9))
+ }
+
+ // ---- update()/handlePad(): the stateful click + slide-detent path (right pad) ----
+ private val RTOUCH = TritonProtocol.BTN_RPAD_TOUCH
+ private val RCLICK = TritonProtocol.BTN_RPAD_CLICK
+ private fun rState(buttons: Int, x: Int = 0, y: Int = 0) =
+ TritonState().apply { this.buttons = buttons; rightPadX = x; rightPadY = y }
+ private fun List.count(cmd: Int) =
+ count { it.size >= 3 && (it[0].toInt() and 0xFF) == TritonHaptics.ID_OUT_HAPTIC_COMMAND && it[2].toInt() == cmd }
+
+ @Test fun `fresh right-pad click fires exactly one click`() {
+ val out = mutableListOf()
+ TritonHaptics { out.add(it) }.update(rState(RTOUCH or RCLICK, 100, 100), prevButtons = 0, HapticSettings())
+ assertEquals(1, out.count(TritonHaptics.CMD_CLICK))
+ assertEquals(0, out.count(TritonHaptics.CMD_TICK)) // touch-down alone doesn't tick
+ }
+
+ @Test fun `sliding past detentStep fires a detent tick`() {
+ val out = mutableListOf()
+ val h = TritonHaptics { out.add(it) }
+ val cfg = HapticSettings()
+ h.update(rState(RTOUCH, 0, 0), prevButtons = 0, cfg) // touch down (accum reset)
+ h.update(rState(RTOUCH, cfg.detentStep, 0), prevButtons = RTOUCH, cfg) // slide one detent's worth
+ assertEquals(1, out.count(TritonHaptics.CMD_TICK))
+ }
+
+ @Test fun `jitter below moveNoise fires no tick`() {
+ val out = mutableListOf()
+ val h = TritonHaptics { out.add(it) }
+ val cfg = HapticSettings()
+ h.update(rState(RTOUCH, 0, 0), prevButtons = 0, cfg)
+ // Feed enough sub-noise steps that their sum crosses detentStep — if the filter accumulated jitter
+ // instead of rejecting it, this would fire a tick. Each per-report delta stays below moveNoise.
+ val step = cfg.moveNoise - 1
+ for (i in 1..(cfg.detentStep / step) + 1) h.update(rState(RTOUCH, i * step, 0), prevButtons = RTOUCH, cfg)
+ assertEquals(0, out.count(TritonHaptics.CMD_TICK))
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/MacroDelayTest.kt b/app/src/test/java/app/gamenative/steamcontroller/MacroDelayTest.kt
new file mode 100644
index 0000000000..53658bf17d
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/MacroDelayTest.kt
@@ -0,0 +1,91 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.xserver.XKeycode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/** Per-binding delays + toggle + macro playback, driven by a virtual clock. */
+@RunWith(RobolectricTestRunner::class)
+class MacroDelayTest {
+
+ private val A = TritonProtocol.BTN_A
+ private fun press() = TritonState().apply { buttons = A }
+ private fun release() = TritonState()
+ private val F = XKeycode.KEY_F
+
+ private class H(binding: Binding) {
+ var t = 0L
+ val sink = RecordingSink()
+ private val interp = ProfileInterpreter(
+ sink, ScProfile(buttons = mapOf(TritonProtocol.BTN_A to binding)), haptics = null, clock = { t },
+ )
+ fun at(time: Long, s: TritonState) { t = time; interp.apply(s) }
+ }
+
+ private fun H.downs(k: XKeycode) = sink.keyPresses(k)
+ private fun H.ups(k: XKeycode) = sink.keys.count { it.key == k && !it.pressed }
+
+ @Test
+ fun `fire start delay defers the press`() {
+ val h = H(Binding(ScOutput.Key(F), delayStartMs = 100))
+ h.at(0, press()); assertEquals(0, h.downs(F)) // not yet
+ h.at(50, press()); assertEquals(0, h.downs(F)) // still waiting
+ h.at(100, press()); assertEquals(1, h.downs(F)) // fires at +100
+ }
+
+ @Test
+ fun `fire end delay defers the release`() {
+ val h = H(Binding(ScOutput.Key(F), delayEndMs = 100))
+ h.at(0, press()); assertEquals(1, h.downs(F))
+ h.at(10, release()); assertEquals(0, h.ups(F)) // release deferred
+ h.at(110, release()); assertEquals(1, h.ups(F)) // released at +110
+ }
+
+ @Test
+ fun `fire-anyway - a tap shorter than the start delay still pulses`() {
+ val h = H(Binding(ScOutput.Key(F), delayStartMs = 100))
+ h.at(0, press())
+ h.at(10, release()) // let go before the 100ms delay elapses
+ h.at(100, release()) // scheduled press fires here
+ h.at(140, release()) // and the clamped release
+ assertEquals("press still fired", 1, h.downs(F))
+ assertEquals("and released", 1, h.ups(F))
+ }
+
+ @Test
+ fun `toggle latches on then off across presses`() {
+ val h = H(Binding(ScOutput.Key(F), toggle = true))
+ h.at(0, press()); h.at(10, release())
+ assertEquals(1, h.downs(F)); assertEquals(0, h.ups(F)) // latched on, still held
+ h.at(100, press()); h.at(110, release())
+ assertEquals(1, h.ups(F)) // second press latches off
+ }
+
+ @Test
+ fun `macro plays its commands in order over time`() {
+ val m = ScOutput.Macro(listOf(
+ MacroCommand(listOf(ScOutput.Key(XKeycode.KEY_1))),
+ MacroCommand(listOf(ScOutput.Key(XKeycode.KEY_2))),
+ ))
+ val h = H(Binding(m))
+ h.at(0, press())
+ for (t in listOf(40L, 80, 120, 160, 200)) h.at(t, release()) // advance frames so scheduled steps fire
+ assertEquals(1, h.downs(XKeycode.KEY_1))
+ assertEquals(1, h.downs(XKeycode.KEY_2))
+ val i1 = h.sink.keys.indexOfFirst { it.key == XKeycode.KEY_1 && it.pressed }
+ val i2 = h.sink.keys.indexOfFirst { it.key == XKeycode.KEY_2 && it.pressed }
+ assertTrue("command 1 fires before command 2", i1 in 0 until i2)
+ }
+
+ @Test
+ fun `macro one-shot - holding does not replay`() {
+ val m = ScOutput.Macro(listOf(MacroCommand(listOf(ScOutput.Key(XKeycode.KEY_1)))))
+ val h = H(Binding(m))
+ h.at(0, press())
+ for (t in listOf(40L, 80, 120, 160, 200, 240)) h.at(t, press()) // keep holding
+ assertEquals("played exactly once", 1, h.downs(XKeycode.KEY_1))
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/MenuModesTest.kt b/app/src/test/java/app/gamenative/steamcontroller/MenuModesTest.kt
new file mode 100644
index 0000000000..648c6f61d6
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/MenuModesTest.kt
@@ -0,0 +1,268 @@
+package app.gamenative.steamcontroller
+
+import app.gamenative.utils.SteamControllerProfileImporter
+import com.winlator.xserver.XKeycode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * Unit tests for the radial/touch menu **selection logic** (build step 6, functional half — no overlay).
+ * Driven by synthetic pad states. Radial = finger angle → slot; touch = grid cell → slot; commit pulses the
+ * slot's binding. Also asserts the importer turns ToME4's menu groups into real modes (no longer dropped).
+ */
+@RunWith(RobolectricTestRunner::class)
+class MenuModesTest {
+
+ private fun leftPad(touch: Boolean, x: Int, y: Int, click: Boolean = false): TritonState {
+ val s = TritonState()
+ var b = 0
+ if (touch) b = b or TritonProtocol.BTN_LPAD_TOUCH
+ if (click) b = b or TritonProtocol.BTN_LPAD_CLICK
+ s.buttons = b
+ s.leftPadX = x; s.leftPadY = y
+ return s
+ }
+
+ private fun slot(key: XKeycode) = MenuSlot(Binding(ScOutput.Key(key)))
+
+ // Radial: slot 0 = up (12 o'clock), clockwise -> 1=right, 2=down, 3=left.
+ private fun radial4() = ScProfile(
+ leftPad = PadMode.RadialMenu(
+ slots = listOf(slot(XKeycode.KEY_F1), slot(XKeycode.KEY_F2), slot(XKeycode.KEY_F3), slot(XKeycode.KEY_F4)),
+ ),
+ )
+
+ @Test
+ fun `radial menu fires the pointed slot on release`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, radial4(), haptics = null)
+ fun point(x: Int, y: Int) {
+ interp.apply(leftPad(touch = true, x = x, y = y)) // highlight
+ interp.apply(leftPad(touch = false, x = 0, y = 0)) // release -> commit
+ }
+ point(0, 30000) // up -> F1
+ point(30000, 0) // right -> F2
+ point(0, -30000) // down -> F3
+ point(-30000, 0) // left -> F4
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F1))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F2))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F3))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F4))
+ }
+
+ @Test
+ fun `radial menu pulses (equal down and up) and centered touch fires nothing`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, radial4(), haptics = null)
+ interp.apply(leftPad(touch = true, x = 0, y = 30000)) // point up
+ interp.apply(leftPad(touch = false, x = 0, y = 0)) // commit F1
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_F1 && it.pressed })
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_F1 && !it.pressed })
+
+ // A touch that never leaves the center dead-zone selects nothing -> no fire on release.
+ interp.apply(leftPad(touch = true, x = 0, y = 0))
+ interp.apply(leftPad(touch = false, x = 0, y = 0))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F1)) // unchanged
+ }
+
+ // Touch menu 2x2: slots row-major, row 0 = TOP. 0=TL 1=TR 2=BL 3=BR.
+ private fun touch4() = ScProfile(
+ leftPad = PadMode.TouchMenu(
+ slots = listOf(slot(XKeycode.KEY_F1), slot(XKeycode.KEY_F2), slot(XKeycode.KEY_F3), slot(XKeycode.KEY_F4)),
+ cols = 2, rows = 2,
+ ),
+ )
+
+ @Test
+ fun `touch menu fires the grid cell under the finger on release`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, touch4(), haptics = null)
+ fun cell(x: Int, y: Int) {
+ interp.apply(leftPad(touch = true, x = x, y = y))
+ interp.apply(leftPad(touch = false, x = x, y = y))
+ }
+ cell(-30000, 30000) // top-left -> F1
+ cell(30000, 30000) // top-right -> F2
+ cell(-30000, -30000) // bottom-left -> F3
+ cell(30000, -30000) // bottom-right -> F4
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F1))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F2))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F3))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F4))
+ }
+
+ @Test
+ fun `touch menu with onClick commits on the click edge`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(
+ sink,
+ ScProfile(leftPad = PadMode.TouchMenu(listOf(slot(XKeycode.KEY_F1), slot(XKeycode.KEY_F2)), cols = 2, rows = 1, onClick = true)),
+ haptics = null,
+ )
+ interp.apply(leftPad(touch = true, x = -30000, y = 0)) // highlight left (F1), no commit
+ assertEquals(0, sink.keyPresses(XKeycode.KEY_F1))
+ interp.apply(leftPad(touch = true, x = -30000, y = 0, click = true)) // click -> commit F1
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F1))
+ }
+
+ @Test
+ fun `release-style touch menu also commits on a click, without double-firing on release`() {
+ // onClick=false (requires_click=0, the common case): clicking is still a valid commit (matches Steam) and
+ // must fire exactly once even though the finger also lifts afterwards.
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, touch4(), haptics = null) // touch4() defaults onClick=false
+ interp.apply(leftPad(touch = true, x = -30000, y = 30000)) // highlight TL (F1)
+ interp.apply(leftPad(touch = true, x = -30000, y = 30000, click = true)) // click -> commit F1 immediately
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F1))
+ interp.apply(leftPad(touch = false, x = 0, y = 0)) // release -> must NOT re-fire
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F1))
+ }
+
+ // (Removed: the global menuCommit override tests — that override was folded away; commit style is now purely
+ // per-menu via each menu's own onClick setting.)
+
+ private fun stickState(lx: Int, ly: Int, l3: Boolean = false, touch: Boolean = false) = TritonState().apply {
+ leftStickX = lx; leftStickY = ly
+ var b = 0
+ if (l3) b = b or TritonProtocol.BTN_L3
+ if (touch) b = b or TritonProtocol.BTN_LSTICK_TOUCH
+ buttons = b
+ }
+
+ @Test
+ fun `stick radial HUD appears on thumb-touch before deflecting`() {
+ val overlay = CapturingMenuOverlay()
+ val interp = ProfileInterpreter(RecordingSink(), stickRadialHold(), haptics = null, menuOverlay = overlay)
+ // Thumb resting on the (centered) stick: HUD shows with nothing highlighted, cursor at the center hub.
+ interp.apply(stickState(0, 0, touch = true))
+ assertEquals(ScMenuSpec.Kind.RADIAL, overlay.last?.kind)
+ assertEquals("LEFT_STICK", overlay.last?.menuId) // the source id is threaded through for per-menu placement
+ assertEquals(-1, overlay.last?.highlighted)
+ assertEquals(0f, overlay.last!!.cursorX, 0.02f)
+ assertEquals(0f, overlay.last!!.cursorY, 0.02f)
+ // Then deflect up while still touching -> slot 0 highlights and the cursor moves up (cursorY -> +1).
+ interp.apply(stickState(0, 32767, touch = true))
+ assertEquals(0, overlay.last?.highlighted)
+ assertTrue("cursor tracks the deflection", overlay.last!!.cursorY > 0.9f)
+ }
+
+ // Stick radial, HOLD activation (movement-radial style): hold the pointed slot's key while deflected.
+ private fun stickRadialHold() = ScProfile(
+ leftStick = StickMode.RadialMenu(
+ slots = listOf(slot(XKeycode.KEY_W), slot(XKeycode.KEY_D), slot(XKeycode.KEY_S), slot(XKeycode.KEY_A)),
+ activation = MenuActivation.HOLD,
+ deadzone = 0.35f,
+ ),
+ )
+
+ @Test
+ fun `stick radial HOLD holds the pointed direction and releases on center`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, stickRadialHold(), haptics = null)
+ interp.apply(stickState(0, 32767)) // up -> hold W
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_W && it.pressed })
+ assertEquals(0, sink.keys.count { it.key == XKeycode.KEY_W && !it.pressed })
+ interp.apply(stickState(0, 0)) // center -> release W
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_W && !it.pressed })
+ }
+
+ @Test
+ fun `stick radial HOLD switches held key when the direction changes`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, stickRadialHold(), haptics = null)
+ interp.apply(stickState(0, 32767)) // up -> W down
+ interp.apply(stickState(32767, 0)) // right -> W up, D down
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_W && !it.pressed })
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_D && it.pressed })
+ }
+
+ @Test
+ fun `movement radial with hold_repeats pulses the direction while held (not a continuous hold)`() {
+ val sink = RecordingSink()
+ var now = 1000L
+ val up = MenuSlot(Binding(ScOutput.Key(XKeycode.KEY_UP), Activator.Turbo(280)))
+ val mode = StickMode.RadialMenu(
+ slots = listOf(up, slot(XKeycode.KEY_RIGHT), slot(XKeycode.KEY_DOWN), slot(XKeycode.KEY_LEFT)),
+ activation = MenuActivation.HOLD, directional = true,
+ )
+ val interp = ProfileInterpreter(sink, ScProfile(leftStick = mode), haptics = null, clock = { now })
+ interp.apply(stickState(0, 32767)) // up -> first pulse
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_UP))
+ now = 1100; interp.apply(stickState(0, 32767)) // before 280ms -> no new pulse
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_UP))
+ now = 1300; interp.apply(stickState(0, 32767)) // past 280ms -> 2nd pulse
+ assertEquals(2, sink.keyPresses(XKeycode.KEY_UP))
+ // each repeat is a press+release pair (a pulse), never a held-down key.
+ assertEquals(2, sink.keys.count { it.key == XKeycode.KEY_UP && !it.pressed })
+ now = 1400; interp.apply(stickState(0, 0)) // center -> disengage, no spurious extra release
+ assertEquals(2, sink.keys.count { it.key == XKeycode.KEY_UP && !it.pressed })
+ }
+
+ @Test
+ fun `ToME4 movement radial ring is Turbo (repeats) and stick-click waits a turn`() {
+ val cfg = SteamControllerProfileImporter.importConfig(load("delf_tome4_neptune.vdf"))
+ // The movement set's left-stick radial: every ring slot carries hold_repeats -> Turbo (so it repeats).
+ val set = cfg.sets.values.first { (it.leftStick as? StickMode.RadialMenu)?.directional == true }
+ val movement = set.leftStick as StickMode.RadialMenu
+ assertTrue("all ring slots repeat (Turbo)", movement.slots.all { it.binding.activator is Activator.Turbo })
+ // Pushing the stick down (L3 click) = the radial's `click` input = "Wait a turn" (KEYPAD_5).
+ assertEquals(XKeycode.KEY_KP_5, (set.buttons[TritonProtocol.BTN_L3]?.output as? ScOutput.Key)?.keys?.first())
+ }
+
+ @Test
+ fun `stick radial inside deadzone fires nothing`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, stickRadialHold(), haptics = null)
+ interp.apply(stickState(3000, 3000)) // ~0.13 magnitude, inside 0.35 deadzone
+ assertEquals(0, sink.keys.count { it.pressed })
+ }
+
+ /** Capturing overlay so tests can assert the HUD labels/center the interpreter pushes. */
+ private class CapturingMenuOverlay : ScMenuOverlay {
+ var last: ScMenuSpec? = null
+ override fun showMenu(spec: ScMenuSpec) { last = spec }
+ override fun hideMenu() {}
+ }
+
+ @Test
+ fun `directional radial labels the ring with 8-way arrows and shows the center`() {
+ val overlay = CapturingMenuOverlay()
+ // 8 ring slots bound to arrow/keypad keys (order N,NE,E,SE,S,SW,W,NW) + a no-op center.
+ val ring = listOf(
+ slot(XKeycode.KEY_UP), slot(XKeycode.KEY_KP_9), slot(XKeycode.KEY_RIGHT), slot(XKeycode.KEY_KP_3),
+ slot(XKeycode.KEY_DOWN), slot(XKeycode.KEY_KP_1), slot(XKeycode.KEY_LEFT), slot(XKeycode.KEY_KP_7),
+ )
+ val mode = StickMode.RadialMenu(ring, directional = true, center = MenuSlot(Binding(ScOutput.MouseNudge(0, 0)), ""))
+ val interp = ProfileInterpreter(RecordingSink(), ScProfile(leftStick = mode), haptics = null, menuOverlay = overlay)
+ interp.apply(stickState(0, 32767)) // deflect up -> HUD pushed, top slot highlighted
+ val spec = overlay.last!!
+ assertEquals(listOf("↑", "↗", "→", "↘", "↓", "↙", "←", "↖"), spec.labels)
+ assertEquals(0, spec.highlighted) // up = ring index 0
+ assertEquals("", spec.centerLabel) // center present (no-op label)
+ }
+
+ @Test
+ fun `importer splits ToME4 movement radial into 8-way directional ring with a center`() {
+ val cfg = SteamControllerProfileImporter.importConfig(load("delf_tome4_neptune.vdf"))
+ val radials = cfg.sets.values.flatMap { listOf(it.leftStick, it.rightStick) }.filterIsInstance()
+ val movement = radials.firstOrNull { it.directional }
+ ?: error("expected a directional (movement) radial; got ${radials.map { it.slots.size }}")
+ assertEquals("ring is the 8 directions (center button_0 pulled out)", 8, movement.slots.size)
+ assertTrue("center button present", movement.center != null)
+ assertEquals("center labelled from the stick-click action", "Wait", movement.center?.label)
+ }
+
+ @Test
+ fun `importer turns ToME4 pad touch_menu into a real TouchMenu (no longer dropped)`() {
+ // ToME4's left trackpad is a reference->touch_menu hotbar grid; it used to import as None.
+ val cfg = SteamControllerProfileImporter.importConfig(load("delf_tome4_neptune.vdf"))
+ val touchMenus = cfg.sets.values.flatMap { listOf(it.leftPad, it.rightPad) }.filterIsInstance()
+ assertTrue("expected at least one TouchMenu from ToME4", touchMenus.isNotEmpty())
+ val m = touchMenus.first()
+ assertTrue("touch menu has resolved slots", m.slots.isNotEmpty())
+ assertEquals(m.cols * m.rows >= m.slots.size, true) // grid fits all slots
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/ModeShiftTest.kt b/app/src/test/java/app/gamenative/steamcontroller/ModeShiftTest.kt
new file mode 100644
index 0000000000..777a376b9e
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/ModeShiftTest.kt
@@ -0,0 +1,40 @@
+package app.gamenative.steamcontroller
+
+import app.gamenative.utils.SteamControllerProfileImporter
+import com.winlator.xserver.XKeycode
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * Mode-shift (build step 3): a `mode_shift ` binding momentarily overlays one source's mode
+ * while its button is held ([ScOutput.ModeShift] + [ScConfig.shiftOverlays], merged via [mergeProfiles] with
+ * `layerSources={source}`). Here holding right_bumper shifts the button_diamond source so A: Q -> M.
+ */
+@RunWith(RobolectricTestRunner::class)
+class ModeShiftTest {
+
+ private fun state(buttons: Int): TritonState = TritonState().apply { this.buttons = buttons }
+
+ @Test
+ fun `mode_shift overlays the source while held and restores on release`() {
+ val cfg = SteamControllerProfileImporter.importConfig(load("modeshift_v3.vdf"))
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, cfg.defaultProfile(), haptics = null).also { it.setConfig(cfg) }
+
+ val a = TritonProtocol.BTN_A
+ val bumper = TritonProtocol.BTN_RBUMPER
+
+ interp.apply(state(0))
+ interp.apply(state(a)); interp.apply(state(0)) // base: A -> Q
+ interp.apply(state(bumper)) // hold bumper -> shift button_diamond to group 1
+ interp.apply(state(bumper or a)) // shifted: A -> M
+ interp.apply(state(bumper))
+ interp.apply(state(0)) // release bumper -> restore base
+ interp.apply(state(a)) // base again: A -> Q
+
+ assertEquals(2, sink.keyPresses(XKeycode.KEY_Q))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_M))
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/PadModesTest.kt b/app/src/test/java/app/gamenative/steamcontroller/PadModesTest.kt
new file mode 100644
index 0000000000..cb012b1eab
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/PadModesTest.kt
@@ -0,0 +1,319 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.xserver.Pointer
+import com.winlator.xserver.XKeycode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/** Unit tests for trackpad source modes, driven by synthetic states (no device needed). */
+@RunWith(RobolectricTestRunner::class)
+class PadModesTest {
+
+ private fun leftPadState(touch: Boolean, x: Int, y: Int, click: Boolean = false): TritonState {
+ val s = TritonState()
+ var b = 0
+ if (touch) b = b or TritonProtocol.BTN_LPAD_TOUCH
+ if (click) b = b or TritonProtocol.BTN_LPAD_CLICK
+ s.buttons = b
+ s.leftPadX = x; s.leftPadY = y
+ return s
+ }
+
+ @Test
+ fun `absolute mouse warps the cursor to the finger position`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(leftPad = PadMode.AbsoluteMouse()), haptics = null)
+ // Center of the pad -> center of the screen.
+ interp.apply(leftPadState(touch = true, x = 0, y = 0))
+ assertEquals(0.5f, sink.lastAbsX, 0.02f)
+ assertEquals(0.5f, sink.lastAbsY, 0.02f)
+ // Finger top-right (pad +X right, +Y up) -> screen top-right (nx→1, ny→0).
+ interp.apply(leftPadState(touch = true, x = 32767, y = 32767))
+ assertTrue("nx near right", sink.lastAbsX > 0.95f)
+ assertTrue("ny near top", sink.lastAbsY < 0.05f)
+ // Not touched -> no absolute move emitted for that report.
+ val before = sink.mouseAbsMoves
+ interp.apply(leftPadState(touch = false, x = 0, y = 0))
+ assertEquals(before, sink.mouseAbsMoves)
+ }
+
+ @Test
+ fun `absolute mouse maps into a screen region`() {
+ val sink = RecordingSink()
+ // Right half of the screen only: center 0.75, width 0.5.
+ val interp = ProfileInterpreter(sink, ScProfile(leftPad = PadMode.AbsoluteMouse(centerX = 0.75f, sizeX = 0.5f)), haptics = null)
+ interp.apply(leftPadState(touch = true, x = 0, y = 0)) // pad center -> region center = 0.75 across
+ assertEquals(0.75f, sink.lastAbsX, 0.02f)
+ }
+
+ @Test
+ fun `relative mouse H-V scale halves the vertical axis`() {
+ // vertScale 0.5: a vertical drag emits half the delta of an equal horizontal drag.
+ fun move(mode: PadMode.Mouse, toX: Int, toY: Int): Pair {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(leftPad = mode), haptics = null)
+ interp.apply(leftPadState(touch = true, x = 0, y = 0)) // anchor
+ interp.apply(leftPadState(touch = true, x = toX, y = toY)) // move
+ return sink.mouseDx to sink.mouseDy
+ }
+ val m = PadMode.Mouse(sensitivity = 1f, vertScale = 0.5f)
+ val (hx, _) = move(m, 3000, 0)
+ val (_, vy) = move(m, 0, 3000)
+ assertEquals("vertical delta is half the horizontal", hx / 2, kotlin.math.abs(vy))
+ }
+
+ @Test
+ fun `absolute mouse rotate output rotates the position vector`() {
+ val sink = RecordingSink()
+ // Full screen, rotated 90°: a finger at the right edge maps to bottom-center (right offset -> down offset).
+ val interp = ProfileInterpreter(sink, ScProfile(leftPad = PadMode.AbsoluteMouse(rotation = 90f)), haptics = null)
+ interp.apply(leftPadState(touch = true, x = 32767, y = 0)) // far right, vertical center
+ assertEquals(0.5f, sink.lastAbsX, 0.02f)
+ assertEquals(1.0f, sink.lastAbsY, 0.02f)
+ }
+
+ @Test
+ fun `mouse position warps the cursor on button press`() {
+ val sink = RecordingSink()
+ val prof = ScProfile(buttons = mapOf(TritonProtocol.BTN_A to Binding(ScOutput.MousePosition(0.5f, 0.25f))))
+ val interp = ProfileInterpreter(sink, prof, haptics = null)
+ interp.apply(TritonState().apply { buttons = TritonProtocol.BTN_A }) // press -> warp
+ assertEquals(1, sink.mouseAbsMoves)
+ assertEquals(0.5f, sink.lastAbsX, 0.01f)
+ assertEquals(0.25f, sink.lastAbsY, 0.01f)
+ }
+
+ private fun leftStickState(x: Int, y: Int): TritonState =
+ TritonState().apply { leftStickX = x; leftStickY = y }
+
+ @Test
+ fun `stick d-pad presses a direction on deflect and releases on recenter`() {
+ val sink = RecordingSink()
+ val prof = ScProfile(leftStick = StickMode.DPad(
+ up = ScOutput.Key(XKeycode.KEY_W), down = ScOutput.Key(XKeycode.KEY_S),
+ left = ScOutput.Key(XKeycode.KEY_A), right = ScOutput.Key(XKeycode.KEY_D)))
+ val interp = ProfileInterpreter(sink, prof, haptics = null)
+ // Full up deflection (+Y is up) -> W pressed once.
+ interp.apply(leftStickState(0, 32767))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_W))
+ // Recenter -> W released, no other direction fired.
+ interp.apply(leftStickState(0, 0))
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_W && !it.pressed })
+ assertEquals(0, sink.keyPresses(XKeycode.KEY_A))
+ }
+
+ private fun dpad(layout: DpadLayout) = StickMode.DPad(
+ up = ScOutput.Key(XKeycode.KEY_W), down = ScOutput.Key(XKeycode.KEY_S),
+ left = ScOutput.Key(XKeycode.KEY_A), right = ScOutput.Key(XKeycode.KEY_D), layout = layout)
+
+ @Test
+ fun `dpad layout modes decide diagonal behavior`() {
+ // A NE diagonal (up + slightly-more right) exercises each layout differently.
+ val ne = leftStickState(24000, 20000) // +X right (D), +Y up (W); |X| > |Y| -> right dominant
+ // 8-way overlap: both cardinals press.
+ RecordingSink().let { s ->
+ ProfileInterpreter(s, ScProfile(leftStick = dpad(DpadLayout.EIGHT_WAY)), haptics = null).apply(ne)
+ assertEquals(1, s.keyPresses(XKeycode.KEY_W)); assertEquals(1, s.keyPresses(XKeycode.KEY_D))
+ }
+ // 4-way: only the dominant axis (right) presses.
+ RecordingSink().let { s ->
+ ProfileInterpreter(s, ScProfile(leftStick = dpad(DpadLayout.FOUR_WAY)), haptics = null).apply(ne)
+ assertEquals(0, s.keyPresses(XKeycode.KEY_W)); assertEquals(1, s.keyPresses(XKeycode.KEY_D))
+ }
+ // Cross gate: a near-perfect diagonal falls in the dead band -> nothing presses.
+ RecordingSink().let { s ->
+ ProfileInterpreter(s, ScProfile(leftStick = dpad(DpadLayout.CROSS_GATE)), haptics = null)
+ .apply(leftStickState(23000, 23000))
+ assertEquals(0, s.keys.count { it.pressed })
+ }
+ }
+
+ @Test
+ fun `pad-as-joystick deflects the chosen stick while touched and recenters on lift`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(leftPad = PadMode.Joystick(Stick.RIGHT)), haptics = null)
+ // Finger full-right on the pad -> right stick deflects +X; the left stick is untouched.
+ interp.apply(leftPadState(touch = true, x = 32767, y = 0))
+ assertTrue("right stick pushed right", sink.lastThumbRX > 0.8f)
+ assertEquals("left stick untouched", 0f, sink.lastThumbLX, 1e-4f)
+ // Lift -> the stick recenters (no residual deflection, unlike a relative-mouse pad).
+ interp.apply(leftPadState(touch = false, x = 32767, y = 0))
+ assertEquals(0f, sink.lastThumbRX, 1e-4f)
+ }
+
+ @Test
+ fun `single-button pad fires on touch and releases on lift`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(leftPad = PadMode.SingleButton(ScOutput.Key(XKeycode.KEY_SPACE))), haptics = null)
+ interp.apply(leftPadState(touch = true, x = 0, y = 0))
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_SPACE && it.pressed })
+ assertEquals(0, sink.keys.count { it.key == XKeycode.KEY_SPACE && !it.pressed })
+ interp.apply(leftPadState(touch = false, x = 0, y = 0))
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_SPACE && !it.pressed })
+ }
+
+ @Test
+ fun `directional swipe pulses the flicked direction`() {
+ val sink = RecordingSink()
+ val prof = ScProfile(leftPad = PadMode.DirectionalSwipe(
+ up = ScOutput.Key(XKeycode.KEY_W), down = ScOutput.Key(XKeycode.KEY_S),
+ left = ScOutput.Key(XKeycode.KEY_A), right = ScOutput.Key(XKeycode.KEY_D), threshold = 8000,
+ ))
+ val interp = ProfileInterpreter(sink, prof, haptics = null)
+ interp.apply(leftPadState(touch = true, x = 0, y = 0)) // anchor
+ interp.apply(leftPadState(touch = true, x = 20000, y = 0)) // flick right past threshold
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_D))
+ interp.apply(leftPadState(touch = true, x = 20000, y = 20000)) // flick up (pad +Y up)
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_W))
+ }
+
+ // 2x2 grid: cells row-major, row 0 = BOTTOM, col 0 = LEFT.
+ // cell0 = bottom-left -> F1
+ // cell1 = bottom-right -> F2
+ // cell2 = top-left -> F3
+ // cell3 = top-right -> F4
+ private fun grid2x2Profile() = ScProfile(
+ leftPad = PadMode.ButtonPadGrid(
+ cols = 2, rows = 2,
+ cells = listOf(
+ ScOutput.Key(XKeycode.KEY_F1), ScOutput.Key(XKeycode.KEY_F2),
+ ScOutput.Key(XKeycode.KEY_F3), ScOutput.Key(XKeycode.KEY_F4),
+ ),
+ ),
+ )
+
+ private fun touchCellThenRelease(interp: ProfileInterpreter, x: Int, y: Int) {
+ interp.apply(leftPadState(touch = true, x = x, y = y))
+ interp.apply(leftPadState(touch = false, x = x, y = y))
+ }
+
+ @Test
+ fun `button pad grid fires the cell under the finger`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, grid2x2Profile(), haptics = null)
+
+ touchCellThenRelease(interp, x = -30000, y = -30000) // bottom-left -> F1
+ touchCellThenRelease(interp, x = 30000, y = -30000) // bottom-right -> F2
+ touchCellThenRelease(interp, x = -30000, y = 30000) // top-left -> F3
+ touchCellThenRelease(interp, x = 30000, y = 30000) // top-right -> F4
+
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F1))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F2))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F3))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F4))
+ }
+
+ @Test
+ fun `grid releases a cell on lift and only one cell active at a time`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, grid2x2Profile(), haptics = null)
+
+ interp.apply(leftPadState(touch = true, x = -30000, y = -30000)) // F1 down
+ interp.apply(leftPadState(touch = false, x = -30000, y = -30000)) // F1 up
+
+ // Equal numbers of down and up for F1, and no other key fired.
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_F1 && it.pressed })
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_F1 && !it.pressed })
+ assertTrue("no F2 expected", sink.keyPresses(XKeycode.KEY_F2) == 0)
+ }
+
+ @Test
+ fun `sliding across cells switches the active cell`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, grid2x2Profile(), haptics = null)
+
+ // Touch bottom-left, slide to bottom-right without lifting, then release.
+ interp.apply(leftPadState(touch = true, x = -30000, y = -30000)) // F1 down
+ interp.apply(leftPadState(touch = true, x = 30000, y = -30000)) // -> F1 up, F2 down
+ interp.apply(leftPadState(touch = false, x = 30000, y = -30000)) // F2 up
+
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F1))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F2))
+ // F1 should have been released when sliding off it.
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_F1 && !it.pressed })
+ }
+
+ // up=F1, down=F2, left=F3, right=F4
+ private fun dpadProfile() = ScProfile(
+ leftPad = PadMode.DPad(
+ up = ScOutput.Key(XKeycode.KEY_F1), down = ScOutput.Key(XKeycode.KEY_F2),
+ left = ScOutput.Key(XKeycode.KEY_F3), right = ScOutput.Key(XKeycode.KEY_F4),
+ ),
+ )
+
+ @Test
+ fun `pad d-pad presses the direction held`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, dpadProfile(), haptics = null)
+ fun dir(x: Int, y: Int) {
+ interp.apply(leftPadState(touch = true, x = x, y = y))
+ interp.apply(leftPadState(touch = false, x = 0, y = 0))
+ }
+ dir(0, 30000) // up -> F1
+ dir(0, -30000) // down -> F2
+ dir(-30000, 0) // left -> F3
+ dir(30000, 0) // right -> F4
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F1))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F2))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F3))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F4))
+ }
+
+ @Test
+ fun `pad d-pad fires two outputs on a diagonal`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, dpadProfile(), haptics = null)
+ interp.apply(leftPadState(touch = true, x = 30000, y = 30000)) // up-right
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F1)) // up
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F4)) // right
+ assertEquals(0, sink.keyPresses(XKeycode.KEY_F2)) // not down
+ }
+
+ @Test
+ fun `pad mouse ignores resting-finger jitter below the floor`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(leftPad = PadMode.Mouse(sensitivity = 1f / 70f, invertY = false, jitterFloor = 12)), haptics = null)
+ interp.apply(leftPadState(touch = true, x = 1000, y = 1000)) // activate
+ // small zero-mean wiggle, each delta < 12 raw units
+ interp.apply(leftPadState(touch = true, x = 1006, y = 998))
+ interp.apply(leftPadState(touch = true, x = 996, y = 1005))
+ interp.apply(leftPadState(touch = true, x = 1004, y = 999))
+ assertEquals("resting jitter should produce no cursor motion", 0, sink.mouseMoves)
+ }
+
+ @Test
+ fun `pad mouse moves on a real drag above the floor`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(leftPad = PadMode.Mouse(sensitivity = 1f / 70f, invertY = false, jitterFloor = 12)), haptics = null)
+ interp.apply(leftPadState(touch = true, x = 0, y = 0)) // activate
+ interp.apply(leftPadState(touch = true, x = 7000, y = 0)) // big drag right -> 100px
+ assertTrue(sink.mouseMoves > 0)
+ assertTrue("moved right", sink.mouseDx > 0)
+ }
+
+ @Test
+ fun `pad mouse accumulates sub-pixel motion instead of truncating it away`() {
+ val sink = RecordingSink()
+ // low sensitivity: each step is <1px but above the jitter floor; old truncation would drop it forever.
+ val interp = ProfileInterpreter(sink, ScProfile(leftPad = PadMode.Mouse(sensitivity = 0.02f, invertY = false, jitterFloor = 12)), haptics = null)
+ interp.apply(leftPadState(touch = true, x = 0, y = 0))
+ repeat(5) { i -> interp.apply(leftPadState(touch = true, x = 30 * (i + 1), y = 0)) } // 30 units/step * 0.02 = 0.6px
+ assertTrue("sub-pixel motion should accumulate into real movement", sink.mouseDx > 0)
+ }
+
+ @Test
+ fun `scroll wheel emits one click per step of travel`() {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, ScProfile(leftPad = PadMode.ScrollWheel(step = 6000)), haptics = null)
+ interp.apply(leftPadState(touch = true, x = 0, y = 0)) // start (no emit)
+ interp.apply(leftPadState(touch = true, x = 0, y = 6000)) // +1 up
+ interp.apply(leftPadState(touch = true, x = 0, y = 12000)) // +1 up
+ interp.apply(leftPadState(touch = true, x = 0, y = 18000)) // +1 up
+ interp.apply(leftPadState(touch = true, x = 0, y = 12000)) // -1 down
+ assertEquals(3, sink.mouseButtonPresses(Pointer.Button.BUTTON_SCROLL_UP))
+ assertEquals(1, sink.mouseButtonPresses(Pointer.Button.BUTTON_SCROLL_DOWN))
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/ProfileInterpreterTest.kt b/app/src/test/java/app/gamenative/steamcontroller/ProfileInterpreterTest.kt
new file mode 100644
index 0000000000..ae9851bd41
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/ProfileInterpreterTest.kt
@@ -0,0 +1,74 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.inputcontrols.ExternalController
+import com.winlator.xserver.Pointer
+import com.winlator.xserver.XKeycode
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * Replays the captured golden trace (real controller, every control exercised) through the default profile
+ * and asserts the interpreter emits the expected outputs. This is the regression guard for the engine: any
+ * change that breaks the default mapping fails here, headlessly, on the PC. See docs/AUTOMATION-PLAN.md.
+ */
+@RunWith(RobolectricTestRunner::class)
+class ProfileInterpreterTest {
+
+ private val trace by lazy { TraceReader.loadStates("sc/golden_ble_001.bin") }
+
+ private fun replay(profile: ScProfile): RecordingSink {
+ val sink = RecordingSink()
+ val interp = ProfileInterpreter(sink, profile, haptics = null)
+ for (s in trace) interp.apply(s)
+ return sink
+ }
+
+ @Test
+ fun `golden trace decodes to a rich sample`() {
+ assertTrue("expected many frames, got ${trace.size}", trace.size > 1500)
+ }
+
+ @Test
+ fun `default profile maps all face buttons and dpad to the virtual pad`() {
+ val sink = replay(ScProfile.default())
+ fun bit(idx: Byte) = (sink.gamepadButtonsSeen and (1 shl idx.toInt())) != 0
+ for (idx in listOf(
+ ExternalController.IDX_BUTTON_A, ExternalController.IDX_BUTTON_B,
+ ExternalController.IDX_BUTTON_X, ExternalController.IDX_BUTTON_Y,
+ ExternalController.IDX_BUTTON_L1, ExternalController.IDX_BUTTON_R1,
+ ExternalController.IDX_BUTTON_L3, ExternalController.IDX_BUTTON_R3,
+ ExternalController.IDX_BUTTON_START, ExternalController.IDX_BUTTON_SELECT,
+ )) {
+ assertTrue("gamepad button idx=$idx never pressed", bit(idx))
+ }
+ assertTrue("not all dpad directions seen", sink.dpadSeen.all { it })
+ }
+
+ @Test
+ fun `default profile drives sticks full range and triggers full pull`() {
+ val sink = replay(ScProfile.default())
+ assertTrue("left stick X did not reach right", sink.maxThumbLX > 0.9f)
+ assertTrue("left stick X did not reach left", sink.minThumbLX < -0.9f)
+ assertTrue("right stick X did not reach right", sink.maxThumbRX > 0.9f)
+ assertTrue("left trigger not fully pulled", sink.maxTriggerL > 0.9f)
+ assertTrue("right trigger not fully pulled", sink.maxTriggerR > 0.9f)
+ }
+
+ @Test
+ fun `default profile maps right pad to mouse motion and pad clicks to mouse buttons`() {
+ val sink = replay(ScProfile.default())
+ assertTrue("right pad produced no mouse motion", sink.mouseMoves > 0)
+ assertTrue("right-pad click did not press left mouse", sink.mouseButtonPresses(Pointer.Button.BUTTON_LEFT) > 0)
+ assertTrue("left-pad click did not press right mouse", sink.mouseButtonPresses(Pointer.Button.BUTTON_RIGHT) > 0)
+ }
+
+ @Test
+ fun `default profile maps the four rear paddles to F1-F4`() {
+ val sink = replay(ScProfile.default())
+ for (key in listOf(XKeycode.KEY_F1, XKeycode.KEY_F2, XKeycode.KEY_F3, XKeycode.KEY_F4)) {
+ assertTrue("paddle key $key never fired", sink.keyPresses(key) > 0)
+ }
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/QuickMenuNavTest.kt b/app/src/test/java/app/gamenative/steamcontroller/QuickMenuNavTest.kt
new file mode 100644
index 0000000000..bca61bbbd2
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/QuickMenuNavTest.kt
@@ -0,0 +1,130 @@
+package app.gamenative.steamcontroller
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * QuickMenu controller access (the [ScUiBridge] seam): the Steam button opens the menu, and while a menu/editor is
+ * captured the controller drives Android focus-nav keys instead of the game.
+ */
+@RunWith(RobolectricTestRunner::class)
+class QuickMenuNavTest {
+
+ private class FakeBridge(var capturing: Boolean = false) : ScUiBridge {
+ var opens = 0
+ var hides = 0
+ val navs = ArrayList()
+ override fun isMenuCapturing() = capturing
+ override fun openQuickMenu() { opens++ }
+ override fun nav(key: ScNavKey) { navs.add(key) }
+ override fun hideCursor() { hides++ }
+ }
+
+ private fun interp(bridge: FakeBridge, profile: ScProfile = ScProfile.default(), sink: RecordingSink = RecordingSink()) =
+ ProfileInterpreter(sink, profile, haptics = null, uiBridge = bridge)
+
+ @Test
+ fun `default profile opens the QuickMenu on the Steam-button press edge`() {
+ val bridge = FakeBridge()
+ val i = interp(bridge)
+ i.apply(TritonState()) // baseline, no buttons
+ i.apply(TritonState().apply { buttons = TritonProtocol.BTN_STEAM }) // rising edge
+ assertEquals(1, bridge.opens)
+ }
+
+ @Test
+ fun `Y sends the HELP nav intent while a menu is captured`() {
+ val bridge = FakeBridge(capturing = true)
+ val i = interp(bridge)
+ i.apply(TritonState())
+ i.apply(TritonState().apply { buttons = TritonProtocol.BTN_Y }) // rising edge
+ assertTrue("Y -> HELP nav", bridge.navs.contains(ScNavKey.HELP))
+ }
+
+ @Test
+ fun `nav cursor is hidden when menu capture ends`() {
+ val bridge = FakeBridge(capturing = true)
+ val i = interp(bridge)
+ i.apply(TritonState()) // capturing -> drives nav, no hide
+ assertEquals(0, bridge.hides)
+ bridge.capturing = false
+ i.apply(TritonState()) // falling edge -> cursor detached exactly once
+ assertEquals(1, bridge.hides)
+ i.apply(TritonState()) // still closed -> not repeatedly hidden
+ assertEquals(1, bridge.hides)
+ }
+
+ @Test
+ fun `Steam button does not re-open while the menu is already captured`() {
+ val bridge = FakeBridge(capturing = true)
+ val i = interp(bridge)
+ i.apply(TritonState())
+ i.apply(TritonState().apply { buttons = TritonProtocol.BTN_STEAM })
+ assertEquals("no open while captured (Steam routes to BACK instead)", 0, bridge.opens)
+ assertEquals(listOf(ScNavKey.BACK), bridge.navs)
+ }
+
+ @Test
+ fun `d-pad, A and B map to focus nav while captured`() {
+ val bridge = FakeBridge(capturing = true)
+ val i = interp(bridge)
+ i.apply(TritonState()) // baseline
+ i.apply(TritonState().apply { buttons = TritonProtocol.BTN_DPAD_UP })
+ i.apply(TritonState()) // release
+ i.apply(TritonState().apply { buttons = TritonProtocol.BTN_A })
+ i.apply(TritonState())
+ i.apply(TritonState().apply { buttons = TritonProtocol.BTN_B })
+ assertEquals(listOf(ScNavKey.UP, ScNavKey.SELECT, ScNavKey.BACK), bridge.navs)
+ }
+
+ @Test
+ fun `bumpers emit tab prev-next while captured`() {
+ val bridge = FakeBridge(capturing = true)
+ val i = interp(bridge)
+ i.apply(TritonState()) // baseline
+ i.apply(TritonState().apply { buttons = TritonProtocol.BTN_LBUMPER }) // LB -> TAB_PREV
+ i.apply(TritonState()) // release
+ i.apply(TritonState().apply { buttons = TritonProtocol.BTN_RBUMPER }) // RB -> TAB_NEXT
+ assertEquals(listOf(ScNavKey.TAB_PREV, ScNavKey.TAB_NEXT), bridge.navs)
+ }
+
+ @Test
+ fun `left stick acts as a d-pad with edge-triggered direction changes`() {
+ val bridge = FakeBridge(capturing = true)
+ val i = interp(bridge)
+ i.apply(TritonState()) // dir none
+ i.apply(TritonState().apply { leftStickY = 20000 }) // into up -> one UP
+ i.apply(TritonState().apply { leftStickY = 22000 }) // still up -> no repeat
+ i.apply(TritonState().apply { leftStickX = -20000 }) // into left -> one LEFT
+ assertEquals(listOf(ScNavKey.UP, ScNavKey.LEFT), bridge.navs)
+ }
+
+ @Test
+ fun `every fixed ScMenuNav control fires its declared nav key`() {
+ // Locks the source-of-truth table to interpreter behavior: if a Control's button is remapped, the tooltip
+ // (which reads the same table) and this test move together, so they can't drift.
+ for (c in ScMenuNav.controls) {
+ val bridge = FakeBridge(capturing = true)
+ val i = interp(bridge)
+ i.apply(TritonState()) // baseline
+ i.apply(TritonState().apply { buttons = c.buttonBit }) // rising edge
+ assertTrue("${c.hint} (${c.desc}) -> ${c.key}", bridge.navs.contains(c.key))
+ }
+ }
+
+ @Test
+ fun `no game output is emitted while a menu is captured`() {
+ val bridge = FakeBridge(capturing = true)
+ val sink = RecordingSink()
+ val i = interp(bridge, sink = sink)
+ i.apply(TritonState())
+ // BTN_A would normally drive a virtual-pad button; while captured it must not reach the game.
+ i.apply(TritonState().apply { buttons = TritonProtocol.BTN_A })
+ assertEquals(0, sink.gamepadFrames)
+ assertTrue(sink.keys.isEmpty())
+ assertTrue(sink.mouseButtons.isEmpty())
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/ScConfigStoreRegistryTest.kt b/app/src/test/java/app/gamenative/steamcontroller/ScConfigStoreRegistryTest.kt
new file mode 100644
index 0000000000..eaf494b77c
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/ScConfigStoreRegistryTest.kt
@@ -0,0 +1,180 @@
+package app.gamenative.steamcontroller
+
+import androidx.test.core.app.ApplicationProvider
+import android.content.Context
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import com.winlator.xserver.XKeycode
+import java.io.File
+
+/**
+ * Tests the per-game named-config registry in [ScConfigStore]: importing/authoring configs, selecting the active
+ * one, duplicate/rename/delete, the [forKey] active-config resolution, and migration of legacy single-config files.
+ */
+@RunWith(RobolectricTestRunner::class)
+class ScConfigStoreRegistryTest {
+
+ private val ctx: Context get() = ApplicationProvider.getApplicationContext()
+ private val key = "GAME_TEST"
+
+ /** A minimal valid Steam config (two action sets) so [ScConfigStore.validate]/parse returns non-empty. */
+ private val vdfTwoSets = SMOKE_CONFIG
+
+ @Before
+ fun clean() {
+ File(ctx.filesDir, "sc_configs").deleteRecursively()
+ }
+
+ @Test
+ fun `empty key has no configs and forKey is null`() {
+ assertTrue(ScConfigStore.listConfigs(ctx, key).isEmpty())
+ assertNull(ScConfigStore.activeConfigId(ctx, key))
+ assertNull(ScConfigStore.forKey(ctx, key))
+ }
+
+ @Test
+ fun `import vdf registers an active config that forKey resolves`() {
+ val id = ScConfigStore.importVdfConfig(ctx, key, vdfTwoSets, "Imported")
+ assertNotNull(id)
+ assertEquals(id, ScConfigStore.activeConfigId(ctx, key))
+ assertEquals(1, ScConfigStore.listConfigs(ctx, key).size)
+ assertEquals(ScConfigKind.VDF, ScConfigStore.listConfigs(ctx, key).first().kind)
+ val cfg = ScConfigStore.forKey(ctx, key)
+ assertNotNull(cfg)
+ assertTrue(cfg!!.sets.isNotEmpty())
+ }
+
+ @Test
+ fun `authored config resolves through forKey and switching action sets is preserved`() {
+ // A two-set authored config: set 0 with A -> switch to set 1.
+ val cfg = ScEditableConfig(
+ sets = listOf(
+ ScEditableSet(
+ id = "0", name = "Base",
+ profile = ScEditableProfile(buttons = mapOf("A" to EditBinding(OutputKind.SWITCH_ACTION_SET, targetSetId = "1"))),
+ ),
+ ScEditableSet(id = "1", name = "Alt"),
+ ),
+ defaultSetId = "0",
+ )
+ assertTrue(ScConfigStore.saveEditableConfig(ctx, key, cfg))
+ val resolved = ScConfigStore.forKey(ctx, key)
+ assertNotNull(resolved)
+ assertEquals(setOf("0", "1"), resolved!!.sets.keys)
+ // The switch binding survived the round-trip into the runtime config.
+ val aBit = ScSource.A.bit
+ val out = resolved.sets["0"]!!.buttons[aBit]?.output
+ assertTrue(out is ScOutput.SwitchActionSet && (out as ScOutput.SwitchActionSet).targetSetId == "1")
+ }
+
+ @Test
+ fun `editing a vdf-active config saves in place as an overlay (no fork)`() {
+ val vdfId = ScConfigStore.importVdfConfig(ctx, key, vdfTwoSets, "vdf")!!
+ assertTrue(ScConfigStore.saveEditableConfig(ctx, key, ScEditableConfig(sets = listOf(ScEditableSet(id = "0", name = "Mine")))))
+ // Lossless edit: no fork — still one config, still the same VDF, still active.
+ assertEquals(1, ScConfigStore.listConfigs(ctx, key).size)
+ assertEquals(vdfId, ScConfigStore.activeConfigId(ctx, key))
+ assertEquals(ScConfigKind.VDF, ScConfigStore.listConfigs(ctx, key).first().kind)
+ assertNotNull(ScConfigStore.forKey(ctx, key))
+ }
+
+ @Test
+ fun `switching active between two configs changes activeConfigId`() {
+ val a = ScConfigStore.importVdfConfig(ctx, key, vdfTwoSets, "A")!!
+ val b = ScConfigStore.duplicateConfig(ctx, key, a, "B")!! // duplicate makes the copy active
+ assertEquals(b, ScConfigStore.activeConfigId(ctx, key))
+ assertTrue(ScConfigStore.setActiveConfig(ctx, key, a))
+ assertEquals(a, ScConfigStore.activeConfigId(ctx, key))
+ }
+
+ @Test
+ fun `editing a vdf preserves its action layers and mode-shift (lossless overlay)`() {
+ ScConfigStore.importVdfConfig(ctx, key, vdfTwoSets, "Smoke")
+ val base = ScConfigStore.forKey(ctx, key)!!
+ // Sanity: the parsed config carries action-layer sources + a mode-shift overlay (the surfaces the old
+ // fork-to-authored path destroyed).
+ assertTrue("base should have layer sources", base.setSources.isNotEmpty())
+ assertTrue("base should have a mode-shift overlay", base.shiftOverlays.isNotEmpty())
+
+ // Seed the editor from the vdf, rebind A in set "0" to F5 (a representable edit), and save.
+ val seeded = ScConfigStore.loadEditableConfig(ctx, key)!!
+ val set0 = seeded.sets.first { it.id == "0" }
+ val edited = seeded.copy(
+ sets = seeded.sets.map {
+ if (it.id == "0") it.copy(profile = it.profile.copy(buttons = it.profile.buttons + ("A" to EditBinding(OutputKind.KEY, keys = listOf("KEY_F5"))))) else it
+ },
+ )
+ assertTrue(ScConfigStore.saveEditableConfig(ctx, key, edited))
+
+ val resolved = ScConfigStore.forKey(ctx, key)!!
+ // The edit applied...
+ val out = resolved.sets["0"]!!.buttons[ScSource.A.bit]?.output
+ assertTrue("A should now be F5", out is ScOutput.Key && (out as ScOutput.Key).keys == listOf(XKeycode.KEY_F5))
+ // ...and the advanced surfaces survived (they'd be empty if the edit had forked a default-based copy).
+ assertTrue("layers preserved", resolved.setSources.isNotEmpty())
+ assertTrue("mode-shift preserved", resolved.shiftOverlays.isNotEmpty())
+ assertEquals(base.sets.keys, resolved.sets.keys)
+ }
+
+ @Test
+ fun `duplicate copies the active config and makes the copy active`() {
+ val id = ScConfigStore.importVdfConfig(ctx, key, vdfTwoSets, "Orig")!!
+ val dupId = ScConfigStore.duplicateConfig(ctx, key, id, "Copy")
+ assertNotNull(dupId)
+ assertEquals(2, ScConfigStore.listConfigs(ctx, key).size)
+ assertEquals(dupId, ScConfigStore.activeConfigId(ctx, key))
+ // Both resolve independently.
+ assertNotNull(ScConfigStore.forKey(ctx, key))
+ assertTrue(ScConfigStore.setActiveConfig(ctx, key, id))
+ assertNotNull(ScConfigStore.forKey(ctx, key))
+ }
+
+ @Test
+ fun `delete removes a config and falls back to a remaining one`() {
+ val a = ScConfigStore.importVdfConfig(ctx, key, vdfTwoSets, "A")!!
+ val b = ScConfigStore.duplicateConfig(ctx, key, a, "B")!! // b is active
+ assertTrue(ScConfigStore.deleteConfig(ctx, key, b))
+ assertEquals(1, ScConfigStore.listConfigs(ctx, key).size)
+ assertEquals(a, ScConfigStore.activeConfigId(ctx, key))
+ assertTrue(ScConfigStore.deleteConfig(ctx, key, a))
+ assertTrue(ScConfigStore.listConfigs(ctx, key).isEmpty())
+ assertNull(ScConfigStore.forKey(ctx, key))
+ }
+
+ @Test
+ fun `legacy vdf and sets files migrate into a registry preserving vdf-active`() {
+ // Simulate the pre-registry on-disk state: a legacy .vdf + .sets.json.
+ val dir = File(ctx.filesDir, "sc_configs").apply { mkdirs() }
+ File(dir, "$key.vdf").writeText(vdfTwoSets)
+ File(dir, "$key.sets.json").writeText(
+ kotlinx.serialization.json.Json.encodeToString(
+ ScEditableConfig.serializer(),
+ ScEditableConfig(sets = listOf(ScEditableSet(id = "0", name = "Authored"))),
+ ),
+ )
+ val configs = ScConfigStore.listConfigs(ctx, key)
+ assertEquals(2, configs.size)
+ // vdf stays active (preserves prior resolution); the authored one is selectable.
+ val active = ScConfigStore.listConfigs(ctx, key).first { it.id == ScConfigStore.activeConfigId(ctx, key) }
+ assertEquals(ScConfigKind.VDF, active.kind)
+ assertTrue(configs.any { it.kind == ScConfigKind.AUTHORED })
+ // Legacy files were consumed.
+ assertFalse(File(dir, "$key.vdf").exists())
+ assertFalse(File(dir, "$key.sets.json").exists())
+ }
+
+ @Test
+ fun `forKey falls back to the shared default key`() {
+ ScConfigStore.importVdfConfig(ctx, ScConfigStore.DEFAULT_KEY, vdfTwoSets, "shared")
+ // A game with no configs of its own resolves the shared default.
+ assertTrue(ScConfigStore.listConfigs(ctx, key).isEmpty())
+ assertNotNull(ScConfigStore.forKey(ctx, key))
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/ScEditableAnalogTest.kt b/app/src/test/java/app/gamenative/steamcontroller/ScEditableAnalogTest.kt
new file mode 100644
index 0000000000..a9b4e9c56b
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/ScEditableAnalogTest.kt
@@ -0,0 +1,164 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.xserver.XKeycode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** Phase-1 of the Steam-style editor: authoring analog surfaces (pad/stick behavior + settings). */
+class ScEditableAnalogTest {
+
+ @Test
+ fun `joystick mode converts to JoystickMove on the chosen stick`() {
+ val a = EditAnalog(AnalogMode.JOYSTICK, deadzonePct = 20, invertY = false, curve = EditCurve.AGGRESSIVE, outputStick = "LEFT")
+ val m = a.toStickMode() as StickMode.JoystickMove
+ assertEquals(Stick.LEFT, m.stick)
+ assertEquals(0.20f, m.deadzone, 1e-4f)
+ assertEquals(false, m.invertY)
+ assertEquals(ResponseCurve.AGGRESSIVE, m.curve)
+ }
+
+ @Test
+ fun `pad mouse mode scales sensitivity off the engine default`() {
+ val m = EditAnalog(AnalogMode.MOUSE, sensitivityPct = 200, invertY = true).toPadMode() as PadMode.Mouse
+ assertEquals(EditAnalog.DEFAULT_PAD_MOUSE_SENS * 2f, m.sensitivity, 1e-6f)
+ assertEquals(true, m.invertY)
+ }
+
+ @Test
+ fun `pad dpad mode maps the four direction outputs`() {
+ val a = EditAnalog(
+ AnalogMode.DPAD,
+ up = EditBinding(OutputKind.KEY, keys = listOf("KEY_W")),
+ down = EditBinding(OutputKind.KEY, keys = listOf("KEY_S")),
+ left = EditBinding(OutputKind.KEY, keys = listOf("KEY_A")),
+ right = EditBinding(OutputKind.KEY, keys = listOf("KEY_D")),
+ )
+ val m = a.toPadMode() as PadMode.DPad
+ assertEquals(listOf(XKeycode.KEY_W), (m.up as ScOutput.Key).keys)
+ assertEquals(listOf(XKeycode.KEY_D), (m.right as ScOutput.Key).keys)
+ }
+
+ @Test
+ fun `cross-kind modes return null so the base mode is kept`() {
+ // JOYSTICK is stick-only -> not a valid pad mode; SCROLL_WHEEL is pad-only -> not a valid stick mode.
+ assertNull(EditAnalog(AnalogMode.JOYSTICK).toPadMode())
+ assertNull(EditAnalog(AnalogMode.SCROLL_WHEEL).toStickMode())
+ }
+
+ @Test
+ fun `toScProfile overrides set surfaces and inherits null ones`() {
+ val base = ScProfile.default() // rightPad = Mouse, leftStick/rightStick = JoystickMove
+ val edit = ScEditableProfile(
+ rightStick = EditAnalog(AnalogMode.MOUSE, sensitivityPct = 100), // override stick -> mouse
+ // leftStick / leftPad / rightPad left null -> inherit base
+ )
+ val p = edit.toScProfile(base)
+ assertTrue("right stick overridden to mouse", p.rightStick is StickMode.Mouse)
+ assertTrue("left stick inherited from base", p.leftStick is StickMode.JoystickMove)
+ assertTrue("right pad inherited from base", p.rightPad is PadMode.Mouse)
+ }
+
+ @Test
+ fun `round-trips the default profile's parametric analog modes`() {
+ val p = ScEditableProfile.from(ScProfile.default()).toScProfile()
+ val rp = p.rightPad as PadMode.Mouse
+ assertEquals(1f / 70f, rp.sensitivity, 1e-6f)
+ assertEquals(true, rp.invertY)
+ val rs = p.rightStick as StickMode.JoystickMove
+ assertEquals(Stick.RIGHT, rs.stick)
+ assertEquals(0.12f, rs.deadzone, 1e-4f)
+ assertTrue(p.leftPad is PadMode.None)
+ }
+
+ @Test
+ fun `radial pad menu round-trips slots, center, directional, activation, onClick`() {
+ val menu = PadMode.RadialMenu(
+ slots = listOf(
+ MenuSlot(Binding(ScOutput.Key(XKeycode.KEY_1)), "One"),
+ MenuSlot(Binding(ScOutput.Key(XKeycode.KEY_2), Activator.Turbo(280)), "Two"),
+ ),
+ onClick = true, activation = MenuActivation.HOLD,
+ center = MenuSlot(Binding(ScOutput.Key(XKeycode.KEY_KP_5)), "Wait"), directional = true,
+ )
+ val rt = EditAnalog.fromPad(menu)!!.toPadMode() as PadMode.RadialMenu
+ assertEquals(2, rt.slots.size)
+ assertEquals("Two", rt.slots[1].label)
+ assertEquals(listOf(XKeycode.KEY_2), (rt.slots[1].binding.output as ScOutput.Key).keys)
+ assertTrue("slot activator preserved", rt.slots[1].binding.activator is Activator.Turbo)
+ assertEquals(true, rt.onClick)
+ assertEquals(MenuActivation.HOLD, rt.activation)
+ assertEquals(true, rt.directional)
+ assertEquals("Wait", rt.center!!.label)
+ }
+
+ @Test
+ fun `touch pad menu round-trips grid + slots`() {
+ val menu = PadMode.TouchMenu(
+ slots = listOf(MenuSlot(Binding(ScOutput.Key(XKeycode.KEY_A)), "A"), MenuSlot(Binding(ScOutput.MouseButton(com.winlator.xserver.Pointer.Button.BUTTON_LEFT)))),
+ cols = 2, rows = 1, onClick = false, activation = MenuActivation.COMMIT,
+ )
+ val rt = EditAnalog.fromPad(menu)!!.toPadMode() as PadMode.TouchMenu
+ assertEquals(2, rt.cols); assertEquals(1, rt.rows)
+ assertEquals(2, rt.slots.size)
+ assertEquals(MenuActivation.COMMIT, rt.activation)
+ }
+
+ @Test
+ fun `button pad grid round-trips cells + grid`() {
+ val menu = PadMode.ButtonPadGrid(
+ cols = 2, rows = 2,
+ cells = listOf(ScOutput.Key(XKeycode.KEY_1), ScOutput.Key(XKeycode.KEY_2), ScOutput.Key(XKeycode.KEY_3), ScOutput.None),
+ onClick = true,
+ )
+ val rt = EditAnalog.fromPad(menu)!!.toPadMode() as PadMode.ButtonPadGrid
+ assertEquals(2, rt.cols); assertEquals(2, rt.rows); assertEquals(true, rt.onClick)
+ assertEquals(listOf(XKeycode.KEY_1), (rt.cells[0] as ScOutput.Key).keys)
+ assertTrue("empty cell stays unbound", rt.cells[3] is ScOutput.None)
+ }
+
+ @Test
+ fun `stick radial menu round-trips slots + deadzone + directional`() {
+ val menu = StickMode.RadialMenu(
+ slots = listOf(MenuSlot(Binding(ScOutput.Key(XKeycode.KEY_KP_8)), "↑")),
+ activation = MenuActivation.HOLD, deadzone = 0.4f, directional = true,
+ )
+ val rt = EditAnalog.fromStick(menu)!!.toStickMode() as StickMode.RadialMenu
+ assertEquals(0.4f, rt.deadzone, 1e-2f)
+ assertEquals(true, rt.directional)
+ assertEquals(MenuActivation.HOLD, rt.activation)
+ }
+
+ @Test
+ fun `a radial with a no-op mouse_delta center is representable (does not bail to inherit)`() {
+ // Steam's common idiom: touch_menu_button_0 bound to `mouse_delta 0 0` as a no-op center. This must NOT
+ // make the whole menu inherit (the bug that hid ToME4's left-stick radial from the editor).
+ val menu = StickMode.RadialMenu(
+ slots = listOf(MenuSlot(Binding(ScOutput.Key(XKeycode.KEY_KP_8)), "↑")),
+ center = MenuSlot(Binding(ScOutput.MouseNudge(0, 0)), "Wait"), directional = true,
+ )
+ val e = EditAnalog.fromStick(menu)
+ assertEquals(AnalogMode.RADIAL, e!!.mode)
+ val rt = e.toStickMode() as StickMode.RadialMenu
+ assertTrue("center preserved as a mouse nudge", rt.center!!.binding.output is ScOutput.MouseNudge)
+ assertEquals(0, (rt.center!!.binding.output as ScOutput.MouseNudge).dx)
+ assertEquals(1, rt.slots.size)
+ }
+
+ @Test
+ fun `a menu with an advanced slot output stays inherit (null) so the overlay preserves it`() {
+ // A slot bound to an output the editor still can't author (mode-shift) -> fromPad bails to null = inherit base.
+ val menu = PadMode.RadialMenu(slots = listOf(MenuSlot(Binding(ScOutput.ModeShift("left_trackpad", "5"))), MenuSlot(Binding(ScOutput.Key(XKeycode.KEY_1)))))
+ assertNull("an unrepresentable menu slot -> whole surface inherits", EditAnalog.fromPad(menu))
+ // And via the profile round-trip, the base menu survives untouched.
+ val base = ScProfile.default().let {
+ ScProfile(name = it.name, buttons = it.buttons, leftStick = it.leftStick, rightStick = it.rightStick,
+ leftPad = it.leftPad, rightPad = menu, leftTrigger = it.leftTrigger, rightTrigger = it.rightTrigger,
+ gyro = it.gyro, haptics = it.haptics)
+ }
+ val edit = ScEditableProfile.from(base)
+ assertNull(edit.rightPad)
+ assertTrue(edit.toScProfile(base).rightPad is PadMode.RadialMenu)
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/ScKeyboardTest.kt b/app/src/test/java/app/gamenative/steamcontroller/ScKeyboardTest.kt
new file mode 100644
index 0000000000..02d00ecc47
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/ScKeyboardTest.kt
@@ -0,0 +1,173 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.xserver.XKeycode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/** Unit tests for the split-trackpad on-screen keyboard ([ScKeyboard]) — selection + commit + shift, headless. */
+@RunWith(RobolectricTestRunner::class)
+class ScKeyboardTest {
+
+ /** Pad coords that land on the center of grid [cell] (row 0 = top), in the ±32768 raw space. */
+ private fun coordsFor(cell: Int): Pair {
+ val col = cell % ScKeyboardLayout.COLS
+ val rowTop = cell / ScKeyboardLayout.COLS
+ val nx = (col + 0.5f) / ScKeyboardLayout.COLS
+ val ny = ((ScKeyboardLayout.ROWS - 1 - rowTop) + 0.5f) / ScKeyboardLayout.ROWS
+ return ((nx - 0.5f) * 65536f).toInt() to ((ny - 0.5f) * 65536f).toInt()
+ }
+
+ private fun right(cell: Int, click: Boolean = false): TritonState {
+ val (x, y) = coordsFor(cell)
+ var b = TritonProtocol.BTN_RPAD_TOUCH
+ if (click) b = b or TritonProtocol.BTN_RPAD_CLICK
+ return TritonState().apply { buttons = b; rightPadX = x; rightPadY = y }
+ }
+
+ private fun left(cell: Int, click: Boolean = false): TritonState {
+ val (x, y) = coordsFor(cell)
+ var b = TritonProtocol.BTN_LPAD_TOUCH
+ if (click) b = b or TritonProtocol.BTN_LPAD_CLICK
+ return TritonState().apply { buttons = b; leftPadX = x; leftPadY = y }
+ }
+
+ private fun rightIndexOf(label: String) = ScKeyboardLayout.RIGHT.indexOfFirst { (it as? KbKey.Chr)?.label == label }
+
+ @Test
+ fun `clicking the right pad over a letter types it`() {
+ val sink = RecordingSink()
+ val kb = ScKeyboard(sink)
+ kb.activate()
+ val y = rightIndexOf("y")
+ kb.update(right(y, click = false)) // hover (sets cursor; no fire)
+ kb.update(right(y, click = true)) // click rising edge -> type
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_Y))
+ }
+
+ @Test
+ fun `inactive keyboard ignores input`() {
+ val sink = RecordingSink()
+ val kb = ScKeyboard(sink) // not activated
+ kb.update(right(rightIndexOf("y"), click = true))
+ assertEquals(0, sink.keys.size)
+ }
+
+ @Test
+ fun `sticky shift capitalizes the next letter only`() {
+ val sink = RecordingSink()
+ val kb = ScKeyboard(sink)
+ kb.activate()
+ val shift = ScKeyboardLayout.LEFT.indexOf(KbKey.Shift)
+ kb.update(left(shift, click = false))
+ kb.update(left(shift, click = true)) // toggle shift on
+ val y = rightIndexOf("y")
+ kb.update(right(y, click = false))
+ kb.update(right(y, click = true)) // type Y with shift held
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_SHIFT_L && it.pressed })
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_SHIFT_L && !it.pressed })
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_Y))
+ // shift was one-shot: a second letter has no shift
+ val u = rightIndexOf("u")
+ kb.update(right(u, click = false))
+ kb.update(right(u, click = true))
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_SHIFT_L && it.pressed }) // still just the one
+ }
+
+ @Test
+ fun `special keys fire space backspace enter and close dismisses`() {
+ val sink = RecordingSink()
+ val kb = ScKeyboard(sink)
+ kb.activate()
+ val enter = ScKeyboardLayout.RIGHT.indexOf(KbKey.Enter)
+ kb.update(right(enter, click = false)); kb.update(right(enter, click = true))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_ENTER))
+
+ val space = ScKeyboardLayout.RIGHT.indexOf(KbKey.Space)
+ kb.update(right(space, click = false)); kb.update(right(space, click = true))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_SPACE))
+
+ val bksp = ScKeyboardLayout.RIGHT.indexOf(KbKey.Backspace)
+ kb.update(right(bksp, click = false)); kb.update(right(bksp, click = true))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_BKSP))
+
+ assertTrue(kb.active)
+ val close = ScKeyboardLayout.RIGHT.indexOf(KbKey.Close)
+ kb.update(right(close, click = false)); kb.update(right(close, click = true))
+ assertFalse("Close should dismiss the keyboard", kb.active)
+ }
+
+ @Test
+ fun `holding a key auto-repeats after the initial delay`() {
+ val sink = RecordingSink()
+ var now = 1000L
+ val kb = ScKeyboard(sink, clock = { now })
+ kb.activate()
+ val bksp = ScKeyboardLayout.RIGHT.indexOf(KbKey.Backspace)
+ kb.update(right(bksp, click = false)) // hover
+ kb.update(right(bksp, click = true)) // rising -> fire #1 (nextFire = 1350)
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_BKSP))
+ now = 1300; kb.update(right(bksp, click = true)) // before delay -> no repeat
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_BKSP))
+ now = 1400; kb.update(right(bksp, click = true)) // past delay -> fire #2 (nextFire = 1490)
+ now = 1500; kb.update(right(bksp, click = true)) // past interval -> fire #3
+ assertEquals(3, sink.keyPresses(XKeycode.KEY_BKSP))
+ now = 1600; kb.update(right(bksp, click = false)) // release -> stop
+ now = 1800; kb.update(right(bksp, click = false))
+ assertEquals(3, sink.keyPresses(XKeycode.KEY_BKSP))
+ }
+
+ @Test
+ fun `holding shift does not repeat-toggle`() {
+ val sink = RecordingSink()
+ var now = 1000L
+ val kb = ScKeyboard(sink, clock = { now })
+ kb.activate()
+ val shift = ScKeyboardLayout.LEFT.indexOf(KbKey.Shift)
+ kb.update(left(shift, click = false))
+ kb.update(left(shift, click = true)) // toggle shift ON once
+ now = 1500; kb.update(left(shift, click = true)) // held well past the repeat delay
+ now = 2000; kb.update(left(shift, click = true))
+ // Shift is one-shot (not repeatable): still armed for exactly one capitalized letter.
+ val y = rightIndexOf("y")
+ kb.update(right(y, click = false)); kb.update(right(y, click = true))
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_SHIFT_L && it.pressed })
+ }
+
+ @Test
+ fun `symbol page types a shifted symbol then Abc returns to letters`() {
+ val sink = RecordingSink()
+ val kb = ScKeyboard(sink)
+ kb.activate()
+ // Toggle to the symbol page via the Sym key (right half).
+ val sym = ScKeyboardLayout.RIGHT.indexOf(KbKey.Sym)
+ kb.update(right(sym, click = false)); kb.update(right(sym, click = true))
+ // "!" is LEFT_SYM[0] = Shift+KEY_1 (forceShift). Clicking it holds Shift around the keycode.
+ assertEquals("!", (ScKeyboardLayout.LEFT_SYM[0] as KbKey.Chr).label)
+ kb.update(left(0, click = false)); kb.update(left(0, click = true))
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_1))
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_SHIFT_L && it.pressed })
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_SHIFT_L && !it.pressed })
+ // Abc key returns to the letter page: same left-half cell 0 is now "1", no forced shift.
+ val abc = ScKeyboardLayout.RIGHT_SYM.indexOf(KbKey.Abc)
+ kb.update(right(abc, click = false)); kb.update(right(abc, click = true))
+ kb.update(left(0, click = false)); kb.update(left(0, click = true))
+ assertEquals(2, sink.keyPresses(XKeycode.KEY_1)) // "!" and "1" both fire KEY_1
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_SHIFT_L && it.pressed }) // but letter '1' held no shift
+ }
+
+ @Test
+ fun `trigger pull also commits`() {
+ val sink = RecordingSink()
+ val kb = ScKeyboard(sink)
+ kb.activate()
+ val p = rightIndexOf("p")
+ val (x, yy) = coordsFor(p)
+ kb.update(TritonState().apply { buttons = TritonProtocol.BTN_RPAD_TOUCH; rightPadX = x; rightPadY = yy })
+ kb.update(TritonState().apply { buttons = TritonProtocol.BTN_RPAD_TOUCH or TritonProtocol.BTN_RTRIG_CLICK; rightPadX = x; rightPadY = yy })
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_P))
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/ScMenuLabelToolTest.kt b/app/src/test/java/app/gamenative/steamcontroller/ScMenuLabelToolTest.kt
new file mode 100644
index 0000000000..a0a9b3baa4
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/ScMenuLabelToolTest.kt
@@ -0,0 +1,61 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.xserver.XKeycode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Test
+
+class ScMenuLabelToolTest {
+ private fun slot(key: XKeycode, label: String = "") = MenuSlot(Binding(ScOutput.Key(key)), label)
+
+ private fun cfg(): ScConfig {
+ val p = ScProfile(
+ rightPad = PadMode.RadialMenu(
+ slots = listOf(slot(XKeycode.KEY_1), slot(XKeycode.KEY_2, "Steam Label"), slot(XKeycode.KEY_3)),
+ ),
+ leftStick = StickMode.RadialMenu(slots = listOf(slot(XKeycode.KEY_W), slot(XKeycode.KEY_A))),
+ )
+ return ScConfig(sets = mapOf("0" to p), defaultSetId = "0")
+ }
+
+ @Test
+ fun `enumerate lists each menu with binding-derived or existing defaults`() {
+ val menus = ScMenuLabelTool.enumerate(cfg())
+ assertEquals(2, menus.size)
+ val pad = menus.first { it.location == ScMenuLocation.RIGHT_PAD }
+ assertEquals("Radial", pad.kind)
+ // slot 0 -> binding name; slot 1 -> its existing label wins; slot 2 -> binding name
+ assertEquals(listOf("1", "Steam Label", "3"), pad.slotDefaults)
+ val stick = menus.first { it.location == ScMenuLocation.LEFT_STICK }
+ assertEquals(listOf("W", "A"), stick.slotDefaults)
+ }
+
+ @Test
+ fun `apply overrides only the targeted slot, leaving others intact`() {
+ val labels = ScMenuLabels(listOf(MenuLabelOverride("0", "RIGHT_PAD", 0, "Heal")))
+ val out = ScMenuLabelTool.apply(cfg(), labels)
+ val pad = out.sets.getValue("0").rightPad as PadMode.RadialMenu
+ assertEquals("Heal", pad.slots[0].label) // overridden
+ assertEquals("Steam Label", pad.slots[1].label) // untouched existing label
+ assertEquals("", pad.slots[2].label) // still default (blank)
+ // a different menu is unaffected
+ val stick = out.sets.getValue("0").leftStick as StickMode.RadialMenu
+ assertEquals("", stick.slots[0].label)
+ }
+
+ @Test
+ fun `apply with no overrides returns the same config unchanged`() {
+ val c = cfg()
+ assertSame(c, ScMenuLabelTool.apply(c, ScMenuLabels()))
+ }
+
+ @Test
+ fun `labelFor matches set, location and slot exactly`() {
+ val labels = ScMenuLabels(listOf(MenuLabelOverride("0", "RIGHT_PAD", 1, "Mount")))
+ assertEquals("Mount", labels.labelFor("0", ScMenuLocation.RIGHT_PAD, 1))
+ assertNull(labels.labelFor("0", ScMenuLocation.RIGHT_PAD, 0))
+ assertNull(labels.labelFor("1", ScMenuLocation.RIGHT_PAD, 1))
+ assertNull(labels.labelFor("0", ScMenuLocation.LEFT_PAD, 1))
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/ScOverlayStoreTest.kt b/app/src/test/java/app/gamenative/steamcontroller/ScOverlayStoreTest.kt
new file mode 100644
index 0000000000..9440b33ba9
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/ScOverlayStoreTest.kt
@@ -0,0 +1,52 @@
+package app.gamenative.steamcontroller
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/** Locks the per-menu placement fallback chain in [ScOverlayStore.forMenu] (the #8 money path). */
+@RunWith(RobolectricTestRunner::class)
+class ScOverlayStoreTest {
+
+ private val ctx: Context get() = ApplicationProvider.getApplicationContext()
+ private val game = "GAME_1"
+ private val menu = "LEFT_PAD"
+
+ @Before
+ fun clean() {
+ ctx.getSharedPreferences("sc_overlay", Context.MODE_PRIVATE).edit().clear().apply()
+ }
+
+ @Test
+ fun `forMenu falls back per-menu-game to per-menu-global to whole-HUD to built-in`() {
+ // Nothing stored → built-in default.
+ assertEquals(ScOverlayLayout(), ScOverlayStore.forMenu(ctx, game, menu))
+
+ // Whole-HUD per-game placement applies to every menu until a per-menu override exists.
+ ScOverlayStore.save(ctx, game, ScOverlayLayout(scale = 1.5f, cx = 0.2f, cy = 0.3f))
+ assertEquals(0.2f, ScOverlayStore.forMenu(ctx, game, menu).cx, 0.001f)
+
+ // A per-menu-global override beats the whole-HUD placement.
+ ScOverlayStore.saveMenu(ctx, ScOverlayStore.DEFAULT_KEY, menu, ScOverlayLayout(cx = 0.6f))
+ assertEquals(0.6f, ScOverlayStore.forMenu(ctx, game, menu).cx, 0.001f)
+
+ // A per-menu-per-game override wins outright.
+ ScOverlayStore.saveMenu(ctx, game, menu, ScOverlayLayout(cx = 0.9f))
+ assertTrue(ScOverlayStore.hasMenu(ctx, game, menu))
+ assertEquals(0.9f, ScOverlayStore.forMenu(ctx, game, menu).cx, 0.001f)
+
+ // A different menu on the same game still gets the whole-HUD placement (not the LEFT_PAD override).
+ assertEquals(0.2f, ScOverlayStore.forMenu(ctx, game, "RIGHT_PAD").cx, 0.001f)
+
+ // Clearing the per-menu override reverts to the per-menu-global (0.6), not built-in.
+ ScOverlayStore.clearMenu(ctx, game, menu)
+ assertFalse(ScOverlayStore.hasMenu(ctx, game, menu))
+ assertEquals(0.6f, ScOverlayStore.forMenu(ctx, game, menu).cx, 0.001f)
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/ScProfileEditorTest.kt b/app/src/test/java/app/gamenative/steamcontroller/ScProfileEditorTest.kt
new file mode 100644
index 0000000000..756692725f
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/ScProfileEditorTest.kt
@@ -0,0 +1,219 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.inputcontrols.ExternalController
+import com.winlator.xserver.Pointer
+import com.winlator.xserver.XKeycode
+import kotlinx.serialization.json.Json
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * Unit tests for the binding-editor data layer ([ScEditableProfile] ⇄ [ScProfile]) + its JSON serialization.
+ * Pure logic, no device — the live store IO ([ScConfigStore]) is exercised on-hardware via the debug receiver.
+ */
+@RunWith(RobolectricTestRunner::class)
+class ScProfileEditorTest {
+
+ private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
+
+ @Test
+ fun `from(default) round-trips the default digital bindings`() {
+ val edit = ScEditableProfile.from(ScProfile.default())
+ val rebuilt = edit.toScProfile()
+ val default = ScProfile.default()
+ // Every editor-exposed source present in the default profile survives the round trip.
+ for (src in ScSource.entries) {
+ assertEquals("source ${src.name}", default.buttons[src.bit], rebuilt.buttons[src.bit])
+ }
+ }
+
+ @Test
+ fun `key binding overrides a source and carries its activator`() {
+ val edit = ScEditableProfile(
+ name = "Custom",
+ buttons = mapOf(
+ ScSource.A.name to EditBinding(
+ kind = OutputKind.KEY,
+ keys = listOf(XKeycode.KEY_SPACE.name),
+ activator = EditActivator.DOUBLE_PRESS,
+ ),
+ ),
+ )
+ val p = edit.toScProfile()
+ val b = p.buttons[ScSource.A.bit]!!
+ assertEquals(ScOutput.Key(listOf(XKeycode.KEY_SPACE)), b.output)
+ assertTrue(b.activator is Activator.DoublePress)
+ }
+
+ @Test
+ fun `NONE explicitly unbinds a source inherited from base`() {
+ // B is bound in the default; an explicit NONE removes it.
+ assertTrue(ScProfile.default().buttons.containsKey(ScSource.B.bit))
+ val edit = ScEditableProfile(buttons = mapOf(ScSource.B.name to EditBinding(OutputKind.NONE)))
+ val p = edit.toScProfile()
+ assertFalse(p.buttons.containsKey(ScSource.B.bit))
+ }
+
+ @Test
+ fun `gamepad and mouse outputs convert`() {
+ val edit = ScEditableProfile(
+ buttons = mapOf(
+ ScSource.REAR_LEFT_TOP.name to EditBinding(
+ kind = OutputKind.GAMEPAD_BUTTON,
+ gamepadIdx = ExternalController.IDX_BUTTON_A.toInt(),
+ ),
+ ScSource.RIGHT_PAD_CLICK.name to EditBinding(
+ kind = OutputKind.MOUSE_BUTTON,
+ mouseButton = Pointer.Button.BUTTON_MIDDLE.name,
+ ),
+ ),
+ )
+ val p = edit.toScProfile()
+ assertEquals(ScOutput.GamepadButton(ExternalController.IDX_BUTTON_A.toInt()), p.buttons[ScSource.REAR_LEFT_TOP.bit]!!.output)
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_MIDDLE), p.buttons[ScSource.RIGHT_PAD_CLICK.bit]!!.output)
+ }
+
+ @Test
+ fun `toScConfig wraps a single default-active set`() {
+ val cfg = ScEditableProfile(name = "Solo").toScConfig()
+ assertEquals(setOf("0"), cfg.sets.keys)
+ assertEquals("0", cfg.defaultSetId)
+ assertEquals("Solo", cfg.defaultProfile().name)
+ }
+
+ @Test
+ fun `json serialization round-trips`() {
+ val edit = ScEditableProfile.from(ScProfile.default())
+ val text = json.encodeToString(ScEditableProfile.serializer(), edit)
+ val back = json.decodeFromString(ScEditableProfile.serializer(), text)
+ assertEquals(edit, back)
+ }
+
+ @Test
+ fun `invalid keycode name degrades to None, not a crash`() {
+ val b = EditBinding(kind = OutputKind.KEY, keys = listOf("NOT_A_REAL_KEY"))
+ assertEquals(ScOutput.None, b.toOutput())
+ // and a None output unbinds rather than throwing
+ val p = ScEditableProfile(buttons = mapOf(ScSource.X.name to b)).toScProfile()
+ assertNull(p.buttons[ScSource.X.bit])
+ }
+
+ // ---- Phase 4: activator timings ----
+
+ @Test
+ fun `activator timing round-trips through ms field`() {
+ val edit = EditBinding(kind = OutputKind.KEY, keys = listOf(XKeycode.KEY_E.name), activator = EditActivator.LONG_PRESS, activatorMs = 750)
+ val act = edit.toActivator()
+ assertEquals(Activator.LongPress(750), act)
+ // and turbo / double-press carry their ms too
+ assertEquals(Activator.Turbo(120), EditBinding(activator = EditActivator.TURBO, activatorMs = 120).toActivator())
+ assertEquals(Activator.DoublePress(250), EditBinding(activator = EditActivator.DOUBLE_PRESS, activatorMs = 250).toActivator())
+ }
+
+ @Test
+ fun `release activator maps to OnRelease`() {
+ assertEquals(Activator.OnRelease, EditBinding(activator = EditActivator.RELEASE).toActivator())
+ }
+
+ @Test
+ fun `action-set switch carries onRelease from the activator (hold-to-shift authoring)`() {
+ // Press-edge switch (default): set A binding "press LB -> set 1".
+ val press = EditBinding(OutputKind.SWITCH_ACTION_SET, targetSetId = "1", activator = EditActivator.REGULAR).toOutput()
+ assertTrue(press is ScOutput.SwitchActionSet && !(press as ScOutput.SwitchActionSet).onRelease)
+ // Release-edge switch: set B binding "release LB -> set 0" — the second half of momentary hold-to-shift.
+ val release = EditBinding(OutputKind.SWITCH_ACTION_SET, targetSetId = "0", activator = EditActivator.RELEASE).toOutput()
+ assertTrue(release is ScOutput.SwitchActionSet && (release as ScOutput.SwitchActionSet).onRelease)
+ // Round-trips back through from(profile): the release switch keeps its RELEASE activator.
+ val base = ScProfile(buttons = mapOf(ScSource.LEFT_BUMPER.bit to Binding(ScOutput.SwitchActionSet("0", onRelease = true))))
+ val rebuilt = ScEditableProfile.from(base)
+ assertEquals(EditActivator.RELEASE, rebuilt.buttons[ScSource.LEFT_BUMPER.name]!!.activator)
+ }
+
+ @Test
+ fun `non-default activator timing survives from(profile) round trip`() {
+ val base = ScProfile(buttons = mapOf(ScSource.A.bit to Binding(ScOutput.Key(XKeycode.KEY_R), Activator.Turbo(200))))
+ val rebuilt = ScEditableProfile.from(base).toScProfile()
+ assertEquals(Activator.Turbo(200), rebuilt.buttons[ScSource.A.bit]!!.activator)
+ }
+
+ @Test
+ fun `pad-mouse smoothing and jitter floor round-trip`() {
+ // Folded from the old global "Touchpad & menus" tuning → per-pad. Non-default values catch a dropped mapping.
+ val pad = EditAnalog(mode = AnalogMode.MOUSE, smoothingPct = 40, jitterFloor = 55).toPadMode() as PadMode.Mouse
+ assertEquals(40, pad.smoothing)
+ assertEquals(55, pad.jitterFloor)
+ val back = EditAnalog.fromPad(pad)!!
+ assertEquals(40, back.smoothingPct)
+ assertEquals(55, back.jitterFloor)
+ }
+
+ // ---- Phase 5: trigger / gyro / haptics ----
+
+ @Test
+ fun `trigger axis and staged modes convert`() {
+ val axis = EditTrigger(TriggerEditMode.AXIS, axis = "GAMEPAD_L2").toRuntime(TriggerAxis.GAMEPAD_R2)
+ assertEquals(TriggerMode.Axis(TriggerAxis.GAMEPAD_L2), axis)
+
+ val staged = EditTrigger(
+ TriggerEditMode.STAGED, axis = "NONE",
+ soft = EditBinding(OutputKind.KEY, keys = listOf(XKeycode.KEY_SHIFT_L.name)),
+ full = EditBinding(OutputKind.MOUSE_BUTTON, mouseButton = Pointer.Button.BUTTON_LEFT.name),
+ softThresholdPct = 30, fullThresholdPct = 95,
+ ).toRuntime(TriggerAxis.GAMEPAD_R2)
+ assertTrue(staged is TriggerMode.Staged)
+ staged as TriggerMode.Staged
+ assertEquals(0.30f, staged.softThreshold, 0.001f)
+ assertEquals(0.95f, staged.fullThreshold, 0.001f)
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_LEFT), staged.full)
+ }
+
+ @Test
+ fun `trigger round-trips from default profile`() {
+ val edit = ScEditableProfile.from(ScProfile.default())
+ val p = edit.toScProfile()
+ assertEquals(ScProfile.default().leftTrigger, p.leftTrigger)
+ assertEquals(ScProfile.default().rightTrigger, p.rightTrigger)
+ }
+
+ @Test
+ fun `gyro mode and gate convert and round-trip`() {
+ val off = EditGyro(GyroEditMode.OFF).toRuntime()
+ assertEquals(GyroMode.None, off)
+ val m = EditGyro(GyroEditMode.MOUSE, sensitivityPct = 200, gate = "LEFT_GRIP").toRuntime()
+ assertTrue(m is GyroMode.Mouse)
+ m as GyroMode.Mouse
+ assertEquals(GyroGate.LEFT_GRIP, m.gate)
+ // from(default).gyro survives the round trip
+ val rebuilt = ScEditableProfile.from(ScProfile.default()).toScProfile()
+ assertEquals(ScProfile.default().gyro, rebuilt.gyro)
+ }
+
+ @Test
+ fun `haptics enable and detent override base, keep gains`() {
+ val base = HapticSettings()
+ val out = EditHaptics(enabled = false, leftPadEnabled = false, rightPadEnabled = true, detentStep = 5000).toRuntime(base)
+ assertFalse(out.enabled)
+ assertFalse(out.leftPadEnabled)
+ assertEquals(5000, out.detentStep)
+ // gains untouched
+ assertEquals(base.clickGain, out.clickGain)
+ assertEquals(base.tickGain, out.tickGain)
+ }
+
+ @Test
+ fun `full editable profile json round-trips with trigger gyro haptics`() {
+ val edit = ScEditableProfile.from(ScProfile.default())
+ val text = json.encodeToString(ScEditableProfile.serializer(), edit)
+ val back = json.decodeFromString(ScEditableProfile.serializer(), text)
+ assertEquals(edit, back)
+ // and the rebuilt runtime profile matches the default's analog/trigger/gyro
+ val p = back.toScProfile()
+ assertEquals(ScProfile.default().gyro, p.gyro)
+ assertEquals(ScProfile.default().rightTrigger, p.rightTrigger)
+ }
+}
diff --git a/app/src/test/java/app/gamenative/steamcontroller/ScTestSupport.kt b/app/src/test/java/app/gamenative/steamcontroller/ScTestSupport.kt
new file mode 100644
index 0000000000..eee35069c5
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/ScTestSupport.kt
@@ -0,0 +1,135 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.inputcontrols.GamepadState
+import com.winlator.xserver.Pointer
+import com.winlator.xserver.XKeycode
+
+/**
+ * Test fakes for the mapping engine. [RecordingSink] captures every output the [ProfileInterpreter] emits
+ * so tests can assert behaviour headlessly; [TraceReader] loads a captured golden trace (real controller
+ * reports) for replay. See docs/AUTOMATION-PLAN.md.
+ */
+
+/** Records all interpreter outputs instead of doing IO. */
+class RecordingSink : ScOutputSink {
+ data class MouseBtn(val button: Pointer.Button, val pressed: Boolean)
+ data class KeyEv(val key: XKeycode, val pressed: Boolean)
+
+ /** Union of all virtual-pad button bits ever seen pressed. */
+ var gamepadButtonsSeen: Int = 0
+ private set
+ val dpadSeen = BooleanArray(4)
+ var maxThumbLX = 0f; var minThumbLX = 0f
+ var maxThumbRX = 0f; var minThumbRX = 0f
+ var maxTriggerL = 0f; var maxTriggerR = 0f
+ // Most-recent per-frame stick values (for tests that need the current deflection, e.g. recenter-on-lift).
+ var lastThumbLX = 0f; var lastThumbRX = 0f; var lastThumbRY = 0f
+ var gamepadFrames = 0; private set
+
+ var mouseDx = 0L; var mouseDy = 0L
+ var mouseMoves = 0; private set
+
+ // Last absolute-mouse target (screen fraction 0..1) + count, for AbsoluteMouse pad tests.
+ var lastAbsX = -1f; var lastAbsY = -1f
+ var mouseAbsMoves = 0; private set
+
+ val mouseButtons = ArrayList()
+ val keys = ArrayList()
+
+ override fun gamepad(state: GamepadState) {
+ gamepadFrames++
+ gamepadButtonsSeen = gamepadButtonsSeen or (state.buttons.toInt() and 0xFFFF)
+ for (i in 0..3) if (state.dpad[i]) dpadSeen[i] = true
+ maxThumbLX = maxOf(maxThumbLX, state.thumbLX); minThumbLX = minOf(minThumbLX, state.thumbLX)
+ maxThumbRX = maxOf(maxThumbRX, state.thumbRX); minThumbRX = minOf(minThumbRX, state.thumbRX)
+ lastThumbLX = state.thumbLX; lastThumbRX = state.thumbRX; lastThumbRY = state.thumbRY
+ maxTriggerL = maxOf(maxTriggerL, state.triggerL); maxTriggerR = maxOf(maxTriggerR, state.triggerR)
+ }
+
+ override fun mouseMove(dx: Int, dy: Int) {
+ mouseMoves++; mouseDx += dx; mouseDy += dy
+ }
+
+ override fun mouseMoveAbs(nx: Float, ny: Float) {
+ mouseAbsMoves++; lastAbsX = nx; lastAbsY = ny
+ }
+
+ override fun mouseButton(button: Pointer.Button, pressed: Boolean) {
+ mouseButtons.add(MouseBtn(button, pressed))
+ }
+
+ override fun key(key: XKeycode, pressed: Boolean) {
+ keys.add(KeyEv(key, pressed))
+ }
+
+ fun keyPresses(key: XKeycode) = keys.count { it.key == key && it.pressed }
+ fun mouseButtonPresses(button: Pointer.Button) = mouseButtons.count { it.button == button && it.pressed }
+}
+
+object TraceReader {
+ /** Load a length-prefixed trace (1-byte length + report bytes per frame) into decoded states. */
+ fun loadStates(resourcePath: String): List {
+ val raw = readResource(resourcePath)
+ val out = ArrayList()
+ var i = 0
+ while (i < raw.size) {
+ val n = raw[i].toInt() and 0xFF; i++
+ if (i + n > raw.size) break
+ val frame = raw.copyOfRange(i, i + n); i += n
+ TritonProtocol.decodeBleState(frame, frame.size)?.let { out.add(it) }
+ }
+ return out
+ }
+
+ private fun readResource(path: String): ByteArray =
+ (javaClass.classLoader ?: ClassLoader.getSystemClassLoader())
+ .getResourceAsStream(path)?.use { it.readBytes() }
+ ?: error("test resource not found: $path")
+}
+
+/** Load a text test resource from `sc/` (shared by the importer/engine tests; was duplicated per test class). */
+fun load(name: String): String =
+ (RecordingSink::class.java.classLoader ?: ClassLoader.getSystemClassLoader())
+ .getResourceAsStream("sc/$name")?.use { it.readBytes().toString(Charsets.UTF_8) }
+ ?: error("missing test resource sc/$name")
+
+/**
+ * Structural fixture exercising all three build-step-3 mechanisms — an action layer (Overlay), a preset switch
+ * (CHANGE_PRESET), and a mode_shift — so the importer decodes multiple sets + a layer + a mode-shift overlay. The
+ * consuming tests assert the decoded set/layer/overlay STRUCTURE, not runtime press outcomes, so the exact
+ * controller_action target numbers below aren't behaviourally asserted. (Formerly TritonBleEngineSelfTest.SMOKE_CONFIG,
+ * relocated to the test source set when the on-device self-test harness was removed.)
+ */
+const val SMOKE_CONFIG = """
+"controller_mappings"
+{
+ "version" "3"
+ "title" "Engine Smoke Test"
+ "controller_type" "controller_triton"
+ "actions" { "Default" { "title" "Default" "legacy_set" "1" } "Combat" { "title" "Combat" "legacy_set" "1" } }
+ "action_layers" { "Overlay" { "title" "Overlay" "legacy_set" "1" "set_layer" "1" "parent_set_name" "Default" } }
+
+ "group" { "id" "0" "mode" "four_buttons" "inputs" {
+ "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "key_press 1" } } } }
+ "button_b" { "activators" { "Full_Press" { "bindings" { "binding" "key_press 2" } } } } } }
+ "group" { "id" "1" "mode" "four_buttons" "inputs" {
+ "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "key_press 9" } } } } } }
+ "group" { "id" "2" "mode" "switches" "inputs" {
+ "left_bumper" { "activators" { "Full_Press" { "bindings" { "binding" "controller_action hold_layer 3 0 0" } } } }
+ "right_bumper" { "activators" { "Full_Press" { "bindings" { "binding" "controller_action CHANGE_PRESET 2 0 0" } } } }
+ "button_back_left" { "activators" { "Full_Press" { "bindings" { "binding" "mode_shift button_diamond 1" } } } } } }
+ "group" { "id" "3" "mode" "four_buttons" "inputs" {
+ "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "key_press 3" } } } } } }
+ "group" { "id" "4" "mode" "switches" "inputs" {
+ "right_bumper" { "activators" { "Full_Press" { "bindings" { "binding" "controller_action CHANGE_PRESET 1 0 0" } } } } } }
+ "group" { "id" "5" "mode" "four_buttons" "inputs" {
+ "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "key_press 8" } } } } } }
+
+ "preset" { "id" "0" "name" "Default" "group_source_bindings" {
+ "0" "button_diamond active" "2" "switch active" "1" "button_diamond active modeshift" } }
+ "preset" { "id" "1" "name" "Combat" "group_source_bindings" {
+ "3" "button_diamond active" "4" "switch active" } }
+ "preset" { "id" "2" "name" "Overlay" "group_source_bindings" {
+ "5" "button_diamond active" } }
+}
+"""
diff --git a/app/src/test/java/app/gamenative/steamcontroller/TriggerModesTest.kt b/app/src/test/java/app/gamenative/steamcontroller/TriggerModesTest.kt
new file mode 100644
index 0000000000..27287ca416
--- /dev/null
+++ b/app/src/test/java/app/gamenative/steamcontroller/TriggerModesTest.kt
@@ -0,0 +1,39 @@
+package app.gamenative.steamcontroller
+
+import com.winlator.xserver.XKeycode
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class TriggerModesTest {
+
+ private fun trigState(rawLeft: Int): TritonState =
+ TritonState().apply { triggerLeft = rawLeft }
+
+ @Test
+ fun `staged trigger fires soft then full at thresholds`() {
+ val sink = RecordingSink()
+ val profile = ScProfile(
+ leftTrigger = TriggerMode.Staged(
+ soft = ScOutput.Key(XKeycode.KEY_F1),
+ full = ScOutput.Key(XKeycode.KEY_F2),
+ softThreshold = 0.4f, fullThreshold = 0.9f,
+ ),
+ )
+ val interp = ProfileInterpreter(sink, profile, haptics = null)
+
+ interp.apply(trigState(0)) // released
+ interp.apply(trigState(16000)) // ~0.49 -> soft (F1)
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F1))
+ assertEquals(0, sink.keyPresses(XKeycode.KEY_F2))
+
+ interp.apply(trigState(31000)) // ~0.95 -> full (F2), soft still held
+ assertEquals(1, sink.keyPresses(XKeycode.KEY_F2))
+
+ interp.apply(trigState(0)) // released -> both up
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_F1 && !it.pressed })
+ assertEquals(1, sink.keys.count { it.key == XKeycode.KEY_F2 && !it.pressed })
+ }
+}
diff --git a/app/src/test/java/app/gamenative/utils/SteamControllerProfileImporterTest.kt b/app/src/test/java/app/gamenative/utils/SteamControllerProfileImporterTest.kt
new file mode 100644
index 0000000000..59f8feac8a
--- /dev/null
+++ b/app/src/test/java/app/gamenative/utils/SteamControllerProfileImporterTest.kt
@@ -0,0 +1,749 @@
+package app.gamenative.utils
+
+import app.gamenative.steamcontroller.Activator
+import app.gamenative.steamcontroller.DpadLayout
+import app.gamenative.steamcontroller.GyroActivation
+import app.gamenative.steamcontroller.GyroGate
+import app.gamenative.steamcontroller.GyroMode
+import app.gamenative.steamcontroller.LayerOpType
+import app.gamenative.steamcontroller.PadMode
+import app.gamenative.steamcontroller.ResponseCurve
+import app.gamenative.steamcontroller.ScOutput
+import app.gamenative.steamcontroller.Stick
+import app.gamenative.steamcontroller.StickMode
+import app.gamenative.steamcontroller.TriggerAxis
+import app.gamenative.steamcontroller.TriggerMode
+import app.gamenative.steamcontroller.TritonProtocol
+import com.winlator.inputcontrols.ExternalController
+import com.winlator.xserver.Pointer
+import com.winlator.xserver.XKeycode
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * Verifies the `.vdf` -> [app.gamenative.steamcontroller.ScProfile] importer against *real* Steam Input
+ * configs (the "coverage guarantee" of docs/STEAM-INPUT-COVERAGE.md): a v2-schema gamepad template, a
+ * v3-schema keyboard+mouse template, and the real 2026-controller `chord_triton.vdf`.
+ */
+@RunWith(RobolectricTestRunner::class)
+class SteamControllerProfileImporterTest {
+
+ private fun load(name: String): String =
+ (javaClass.classLoader ?: ClassLoader.getSystemClassLoader())
+ .getResourceAsStream("sc/$name")?.use { it.readBytes().toString(Charsets.UTF_8) }
+ ?: error("missing test resource sc/$name")
+
+ private fun gamepadBtn(idx: Byte) = ScOutput.GamepadButton(idx.toInt())
+
+ @Test
+ fun `hand-authored DOOM test config imports to the intended modes`() {
+ val cfg = SteamControllerProfileImporter.importConfig(load("doom_sc_test.vdf"))
+ val p = cfg.defaultProfile()
+ assertTrue("left stick = joystick", p.leftStick is StickMode.JoystickMove)
+ assertTrue("right stick = joystick", p.rightStick is StickMode.JoystickMove)
+ assertTrue("left pad = directional swipe", p.leftPad is PadMode.DirectionalSwipe)
+ assertTrue("right pad = relative mouse", p.rightPad is PadMode.Mouse)
+ assertTrue("gyro = joystick (camera)", p.gyro is GyroMode.Joystick)
+ assertEquals(TriggerAxis.GAMEPAD_R2, (p.rightTrigger as TriggerMode.Axis).axis)
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_A), p.buttons[TritonProtocol.BTN_A]?.output)
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_L1), p.buttons[TritonProtocol.BTN_LBUMPER]?.output)
+ // swipe up = mouse-wheel weapon cycle
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_SCROLL_UP), (p.leftPad as PadMode.DirectionalSwipe).up)
+ }
+ @Test
+ fun `region + single_button test config decodes the B1 B2 modes`() {
+ val p = SteamControllerProfileImporter.importConfig(load("sc_region_single_test.vdf")).defaultProfile()
+ // B2: left pad = single_button bound to key F (fires the whole surface as one key)
+ val lp = p.leftPad as PadMode.SingleButton
+ assertEquals(ScOutput.Key(listOf(XKeycode.KEY_F)), lp.output)
+ // B1: right pad = mouse_region, left-quarter (center 0.25) x 30% wide, both axes inverted
+ val rp = p.rightPad as PadMode.AbsoluteMouse
+ assertEquals(0.25f, rp.centerX, 1e-4f)
+ assertEquals(0.30f, rp.sizeX, 1e-4f)
+ assertTrue(rp.invertX && rp.invertY)
+ }
+
+ @Test
+ fun `stick dpad mode decodes to StickMode DPad`() {
+ val vdf = """
+ "controller_mappings" {
+ "version" "3"
+ "controller_type" "controller_triton"
+ "group" { "id" "0" "mode" "dpad"
+ "inputs" {
+ "dpad_north" { "activators" { "Full_Press" { "bindings" { "binding" "key_press W" } } } }
+ "dpad_south" { "activators" { "Full_Press" { "bindings" { "binding" "key_press S" } } } }
+ "dpad_west" { "activators" { "Full_Press" { "bindings" { "binding" "key_press A" } } } }
+ "dpad_east" { "activators" { "Full_Press" { "bindings" { "binding" "key_press D" } } } }
+ }
+ }
+ "preset" { "id" "0" "name" "Default"
+ "group_source_bindings" { "0" "joystick active" }
+ }
+ }
+ """.trimIndent()
+ val d = SteamControllerProfileImporter.importConfig(vdf).defaultProfile().leftStick as StickMode.DPad
+ assertEquals(ScOutput.Key(listOf(XKeycode.KEY_W)), d.up)
+ assertEquals(ScOutput.Key(listOf(XKeycode.KEY_A)), d.left)
+ assertEquals(ScOutput.Key(listOf(XKeycode.KEY_D)), d.right)
+ assertEquals(DpadLayout.EIGHT_WAY, d.layout) // no `layout` setting -> 8-way default
+ }
+
+ @Test
+ fun `relative mouse pad decodes rotation and H-V scale`() {
+ val vdf = """
+ "controller_mappings" { "version" "3" "controller_type" "controller_triton"
+ "group" { "id" "0" "mode" "mouse" "inputs" {}
+ "settings" { "rotation" "8" "sensitivity_vert_scale" "89" "sensitivity_horiz_scale" "150" } }
+ "preset" { "id" "0" "name" "Default" "group_source_bindings" { "0" "left_trackpad active" } } }
+ """.trimIndent()
+ val m = SteamControllerProfileImporter.importConfig(vdf).defaultProfile().leftPad as PadMode.Mouse
+ assertEquals(8f, m.rotation, 1e-4f)
+ assertEquals(0.89f, m.vertScale, 1e-4f)
+ assertEquals(1.5f, m.horizScale, 1e-4f)
+ }
+
+ @Test
+ fun `stick response curve decodes from curve_exponent`() {
+ fun stickWith(settings: String): StickMode {
+ val vdf = """
+ "controller_mappings" { "version" "3" "controller_type" "controller_triton"
+ "group" { "id" "0" "mode" "joystick_move" "inputs" {} "settings" { $settings } }
+ "preset" { "id" "0" "name" "Default" "group_source_bindings" { "0" "joystick active" } } }
+ """.trimIndent()
+ return SteamControllerProfileImporter.importConfig(vdf).defaultProfile().leftStick
+ }
+ // curve_exponent is the literal power exponent: 1=LINEAR (m¹), 2=AGGRESSIVE (m²), 3=WIDE (m³).
+ assertEquals(ResponseCurve.LINEAR, (stickWith(""" "curve_exponent" "1" """) as StickMode.JoystickMove).curve)
+ assertEquals(ResponseCurve.AGGRESSIVE, (stickWith(""" "curve_exponent" "2" """) as StickMode.JoystickMove).curve)
+ assertEquals(ResponseCurve.WIDE, (stickWith(""" "curve_exponent" "3" """) as StickMode.JoystickMove).curve)
+ assertEquals(ResponseCurve.LINEAR, (stickWith("") as StickMode.JoystickMove).curve) // absent -> linear
+ // custom slider: 200 ~ linear, low ~ aggressive.
+ assertEquals(ResponseCurve.AGGRESSIVE, (stickWith(""" "custom_curve_exponent" "100" """) as StickMode.JoystickMove).curve)
+ }
+
+ @Test
+ fun `dpad layout and overlap settings decode`() {
+ val vdf = """
+ "controller_mappings" { "version" "3" "controller_type" "controller_triton"
+ "group" { "id" "0" "mode" "dpad"
+ "inputs" { "dpad_north" { "activators" { "Full_Press" { "bindings" { "binding" "key_press W" } } } } }
+ "settings" { "layout" "3" "overlap_region" "8000" } }
+ "preset" { "id" "0" "name" "Default" "group_source_bindings" { "0" "joystick active" } } }
+ """.trimIndent()
+ val d = SteamControllerProfileImporter.importConfig(vdf).defaultProfile().leftStick as StickMode.DPad
+ assertEquals(DpadLayout.CROSS_GATE, d.layout)
+ assertEquals(8000f / 32768f, d.overlap, 1e-4f)
+ }
+
+ @Test
+ fun `new-modes test config decodes stick-dpad and pad-joystick`() {
+ val p = SteamControllerProfileImporter.importConfig(load("sc_newmodes_test.vdf")).defaultProfile()
+ val d = p.leftStick as StickMode.DPad // stick-as-dpad -> WASD
+ assertEquals(key(XKeycode.KEY_W), d.up); assertEquals(key(XKeycode.KEY_D), d.right)
+ assertEquals(PadMode.Joystick(Stick.RIGHT), p.leftPad) // pad-as-joystick, output_joystick "2" -> RIGHT
+ }
+
+ @Test
+ fun `macros and per-binding delay-toggle decode`() {
+ val vdf = """
+ "controller_mappings" {
+ "version" "3"
+ "controller_type" "controller_triton"
+ "group" { "id" "0" "mode" "four_buttons"
+ "inputs" {
+ "button_a" { "activators" {
+ "Full_Press" { "bindings" { "binding" "key_press 1" } }
+ "Full_Press" { "bindings" { "binding" "key_press 2" } "settings" { "delay_start" "50" } }
+ } }
+ "button_b" { "activators" {
+ "Full_Press" { "bindings" { "binding" "key_press F" } "settings" { "delay_start" "76" "delay_end" "308" "toggle" "1" } }
+ } }
+ "button_x" { "activators" {
+ "Full_Press" { "bindings" { "binding" "key_press LEFT_SHIFT" "binding" "key_press TAB" } }
+ } }
+ }
+ }
+ "preset" { "id" "0" "name" "Default" "group_source_bindings" { "0" "button_diamond active" } }
+ }
+ """.trimIndent()
+ val p = SteamControllerProfileImporter.importConfig(vdf).defaultProfile()
+ // Repeated Full_Press blocks -> a 2-command macro; per-command delay is carried
+ val macro = p.buttons[TritonProtocol.BTN_A]!!.output as ScOutput.Macro
+ assertEquals(2, macro.commands.size)
+ assertEquals(key(XKeycode.KEY_1), macro.commands[0].outputs.single())
+ assertEquals(key(XKeycode.KEY_2), macro.commands[1].outputs.single())
+ assertEquals(50L, macro.commands[1].delayStartMs)
+ // Per-binding delay + toggle on a single binding
+ val bb = p.buttons[TritonProtocol.BTN_B]!!
+ assertEquals(76L, bb.delayStartMs); assertEquals(308L, bb.delayEndMs); assertTrue(bb.toggle)
+ // A single command with two keys stays a HELD COMBO (not a macro) -> preserves Alt+Tab-style combos
+ val combo = p.buttons[TritonProtocol.BTN_X]!!.output as ScOutput.Key
+ assertEquals(2, combo.keys.size)
+ }
+
+ @Test
+ fun `bind-settings test config decodes macro delay and toggle`() {
+ val p = SteamControllerProfileImporter.importConfig(load("sc_bindsettings_test.vdf")).defaultProfile()
+ assertEquals(2, (p.buttons[TritonProtocol.BTN_A]!!.output as ScOutput.Macro).commands.size)
+ assertTrue(p.buttons[TritonProtocol.BTN_B]!!.toggle)
+ assertEquals(500L, p.buttons[TritonProtocol.BTN_X]!!.delayStartMs)
+ assertEquals(500L, p.buttons[TritonProtocol.BTN_Y]!!.delayEndMs)
+ }
+
+ @Test
+ fun `gyro + mouse-joystick test config decodes the intended modes`() {
+ val p = SteamControllerProfileImporter.importConfig(load("sc_gyro_mousejoy_test.vdf")).defaultProfile()
+ assertTrue("gyro = mouse aim", p.gyro is GyroMode.Mouse)
+ assertTrue("right pad = mouse joystick", p.rightPad is PadMode.MouseJoystick)
+ assertTrue("left stick = joystick", p.leftStick is StickMode.JoystickMove)
+ }
+
+ @Test
+ fun `surface touch input binds an output to the touch bit`() {
+ // Steam writes a bound surface-touch as a "touch" input in the group (alongside "click"), per a real export:
+ // left stick (joystick) click -> L3, touch -> right bumper.
+ val vdf = """
+ "controller_mappings" { "version" "3" "controller_type" "controller_triton"
+ "group" { "id" "0" "mode" "joystick_move" "inputs" {
+ "click" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button JOYSTICK_LEFT" } } } }
+ "touch" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SHOULDER_RIGHT" } } } } } }
+ "preset" { "id" "0" "name" "Default" "group_source_bindings" { "0" "joystick active" } } }
+ """.trimIndent()
+ val p = SteamControllerProfileImporter.importConfig(vdf).defaultProfile()
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_L3), p.buttons[TritonProtocol.BTN_L3]?.output)
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_R1), p.buttons[TritonProtocol.BTN_LSTICK_TOUCH]?.output)
+ }
+
+ @Test
+ fun `touch-bind device config binds each surface touch to its key`() {
+ val p = SteamControllerProfileImporter.importConfig(load("sc_touch_bind_test.vdf")).defaultProfile()
+ assertEquals(key(XKeycode.KEY_1), p.buttons[TritonProtocol.BTN_LPAD_TOUCH]?.output)
+ assertEquals(key(XKeycode.KEY_2), p.buttons[TritonProtocol.BTN_RPAD_TOUCH]?.output)
+ assertEquals(key(XKeycode.KEY_3), p.buttons[TritonProtocol.BTN_LSTICK_TOUCH]?.output)
+ assertEquals(key(XKeycode.KEY_4), p.buttons[TritonProtocol.BTN_RSTICK_TOUCH]?.output)
+ }
+
+ @Test
+ fun `gyro ratchet touch mask maps to the any-touch gate`() {
+ fun gyroWithMask(settings: String): GyroMode {
+ val vdf = """
+ "controller_mappings" { "version" "3" "controller_type" "controller_triton"
+ "group" { "id" "0" "mode" "gyro_to_mouse" "inputs" {} "settings" { $settings } }
+ "preset" { "id" "0" "name" "Default" "group_source_bindings" { "0" "gyro active" } } }
+ """.trimIndent()
+ return SteamControllerProfileImporter.importConfig(vdf).defaultProfile().gyro
+ }
+ // 211106234105856 = bits {19,20,46,47} = the 4 touch surfaces (real 2026-07-06 export) -> touch-to-aim
+ assertEquals(GyroGate.ANY_TOUCH, (gyroWithMask(""" "gyro_ratchet_button_mask" "211106234105856" """) as GyroMode.Mouse).gate)
+ assertEquals(GyroGate.EITHER_GRIP, (gyroWithMask("") as GyroMode.Mouse).gate) // no mask -> grip default
+ }
+
+ @Test
+ fun `gyro touch-to-aim device config decodes to any-touch`() {
+ val p = SteamControllerProfileImporter.importConfig(load("sc_gyro_touch_test.vdf")).defaultProfile()
+ assertEquals(GyroGate.ANY_TOUCH, (p.gyro as GyroMode.Mouse).gate)
+ }
+
+ @Test
+ fun `real gyro-gate capture imports without choking`() {
+ // A messy experimental export with macros (testMacro1), single_button, mouse_joystick, flickstick, mouse_region,
+ // and gyro ratchet masks — a broad stress test that nothing leaks None / throws.
+ val cfg = SteamControllerProfileImporter.importConfig(load("testgyro_gate_capture.vdf"))
+ assertTrue("expected action sets", cfg.sets.isNotEmpty())
+ assertFalse("no None leaked into buttons", cfg.defaultProfile().buttons.values.any { it.output is ScOutput.None })
+ }
+
+ @Test
+ fun `gyro_to_joystick honors output_joystick for the target stick`() {
+ fun gyroWith(oj: String): GyroMode {
+ val vdf = """
+ "controller_mappings" { "version" "3" "controller_type" "controller_triton"
+ "group" { "id" "0" "mode" "gyro_to_joystick" "inputs" {} "settings" { "output_joystick" "$oj" } }
+ "preset" { "id" "0" "name" "Default" "group_source_bindings" { "0" "gyro active" } } }
+ """.trimIndent()
+ return SteamControllerProfileImporter.importConfig(vdf).defaultProfile().gyro
+ }
+ assertEquals(Stick.LEFT, (gyroWith("1") as GyroMode.Joystick).stick)
+ assertEquals(Stick.RIGHT, (gyroWith("2") as GyroMode.Joystick).stick)
+ }
+
+ @Test
+ fun `gyro_button_invert decodes the enable-suppress-toggle activation`() {
+ // Values confirmed from labeled Steam exports (testGyroEnable/Suppress/Toggle): absent=Enable, "0"=Suppress,
+ // "2"=Toggle. `gyro_button` reads "1" for all three, so it is NOT the mode (regression: don't read it).
+ fun gyroFor(settings: String): GyroMode {
+ val vdf = """
+ "controller_mappings" { "version" "3" "controller_type" "controller_triton"
+ "group" { "id" "0" "mode" "gyro_to_mouse" "inputs" {} "settings" { $settings } }
+ "preset" { "id" "0" "name" "Default" "group_source_bindings" { "0" "gyro active" } } }
+ """.trimIndent()
+ return SteamControllerProfileImporter.importConfig(vdf).defaultProfile().gyro
+ }
+ assertEquals(GyroActivation.ENABLE, (gyroFor(""" "gyro_button" "1" """) as GyroMode.Mouse).activation) // absent invert
+ assertEquals(GyroActivation.SUPPRESS, (gyroFor(""" "gyro_button_invert" "0" """) as GyroMode.Mouse).activation)
+ assertEquals(GyroActivation.TOGGLE, (gyroFor(""" "gyro_button_invert" "2" """) as GyroMode.Mouse).activation)
+ }
+
+ @Test
+ fun `gyro joystick camera vs deflection sets the deflection flag`() {
+ fun gyroFor(mode: String): GyroMode {
+ val vdf = """
+ "controller_mappings" { "version" "3" "controller_type" "controller_triton"
+ "group" { "id" "0" "mode" "$mode" "inputs" {} "settings" {} }
+ "preset" { "id" "0" "name" "Default" "group_source_bindings" { "0" "gyro active" } } }
+ """.trimIndent()
+ return SteamControllerProfileImporter.importConfig(vdf).defaultProfile().gyro
+ }
+ assertEquals(false, (gyroFor("gyro_to_joystick_camera") as GyroMode.Joystick).deflection)
+ assertEquals(true, (gyroFor("gyro_to_joystick_deflection") as GyroMode.Joystick).deflection)
+ }
+
+ @Test
+ fun `gyro joystick power curve decodes from the exponent x100`() {
+ val vdf = """
+ "controller_mappings" { "version" "3" "controller_type" "controller_triton"
+ "group" { "id" "0" "mode" "gyro_to_joystick_camera" "inputs" {} "settings" { "gyro_to_joystick_power_curve" "400" } }
+ "preset" { "id" "0" "name" "Default" "group_source_bindings" { "0" "gyro active" } } }
+ """.trimIndent()
+ assertEquals(4f, (SteamControllerProfileImporter.importConfig(vdf).defaultProfile().gyro as GyroMode.Joystick).powerCurve, 1e-4f)
+ }
+
+ private fun padWithMode(mode: String): PadMode {
+ val vdf = """
+ "controller_mappings" { "version" "3" "controller_type" "controller_triton"
+ "group" { "id" "0" "mode" "$mode" "inputs" {} }
+ "preset" { "id" "0" "name" "Default" "group_source_bindings" { "0" "right_trackpad active" } } }
+ """.trimIndent()
+ return SteamControllerProfileImporter.importConfig(vdf).defaultProfile().rightPad
+ }
+ private fun stickWithMode(mode: String): StickMode {
+ val vdf = """
+ "controller_mappings" { "version" "3" "controller_type" "controller_triton"
+ "group" { "id" "0" "mode" "$mode" "inputs" {} }
+ "preset" { "id" "0" "name" "Default" "group_source_bindings" { "0" "joystick active" } } }
+ """.trimIndent()
+ return SteamControllerProfileImporter.importConfig(vdf).defaultProfile().leftStick
+ }
+
+ /** Regression guard for the mode-name→type map (the class of bug that made `absolute_mouse` an absolute region).
+ * ONLY `mouse_region` is absolute; every other mouse name is relative. Stick→mouse accepts both name variants. */
+ @Test
+ fun `mouse-family modes map to the correct type`() {
+ assertTrue("mouse_region = absolute region", padWithMode("mouse_region") is PadMode.AbsoluteMouse)
+ assertTrue("absolute_mouse = RELATIVE (legacy name, not a region)", padWithMode("absolute_mouse") is PadMode.Mouse)
+ assertTrue("mouse = relative", padWithMode("mouse") is PadMode.Mouse)
+ assertTrue("relative_mouse = relative", padWithMode("relative_mouse") is PadMode.Mouse)
+ assertTrue("mouse_joystick pad = self-centering mouse joystick", padWithMode("mouse_joystick") is PadMode.MouseJoystick)
+ assertTrue("joystick_move pad = pad-as-joystick", padWithMode("joystick_move") is PadMode.Joystick)
+ assertTrue("joystick_mouse stick = stick→mouse", stickWithMode("joystick_mouse") is StickMode.Mouse)
+ assertTrue("mouse_joystick stick (name variant) = stick→mouse, not dropped", stickWithMode("mouse_joystick") is StickMode.Mouse)
+ assertTrue("joystick_move stick = joystick", stickWithMode("joystick_move") is StickMode.JoystickMove)
+ }
+
+ private fun key(k: XKeycode) = ScOutput.Key(listOf(k))
+
+ // ---- v2 schema: gamepad_joystick.vdf (flat bindings + switch_bindings, xinput outputs) ----------
+
+ @Test
+ fun `v2 gamepad config maps buttons sticks triggers`() {
+ val p = SteamControllerProfileImporter.import(load("gamepad_joystick.vdf"))
+ val b = p.buttons
+
+ // button_diamond -> face buttons
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_A), b[TritonProtocol.BTN_A]?.output)
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_B), b[TritonProtocol.BTN_B]?.output)
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_X), b[TritonProtocol.BTN_X]?.output)
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_Y), b[TritonProtocol.BTN_Y]?.output)
+
+ // left_trackpad is bound to a dpad-mode group -> pad d-pad, dirs emit virtual d-pad
+ // (our GamepadDpad index order is 0=up,1=right,2=down,3=left)
+ val pad = p.leftPad as PadMode.DPad
+ assertEquals(ScOutput.GamepadDpad(0), pad.up)
+ assertEquals(ScOutput.GamepadDpad(2), pad.down)
+ assertEquals(ScOutput.GamepadDpad(3), pad.left)
+ assertEquals(ScOutput.GamepadDpad(1), pad.right)
+
+ // joystick source -> left stick JoystickMove; its click -> L3
+ assertEquals(StickMode.JoystickMove(Stick.LEFT), p.leftStick)
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_L3), b[TritonProtocol.BTN_L3]?.output)
+
+ // right_trackpad bound to a joystick_move group -> pad-as-joystick (drives a virtual stick); its click -> R3.
+ // No output_joystick set -> defaults to the RIGHT (camera) stick.
+ assertEquals(PadMode.Joystick(Stick.RIGHT), p.rightPad)
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_R3), b[TritonProtocol.BTN_RPAD_CLICK]?.output)
+
+ // analog triggers (click -> xinput TRIGGER_*) -> Axis, no digital click button
+ assertEquals(TriggerMode.Axis(TriggerAxis.GAMEPAD_L2), p.leftTrigger)
+ assertEquals(TriggerMode.Axis(TriggerAxis.GAMEPAD_R2), p.rightTrigger)
+ assertFalse(b.containsKey(TritonProtocol.BTN_LTRIG_CLICK))
+
+ // switch_bindings (v2 top-level): start/select/bumpers/paddles.
+ // (button_escape -> BTN_MENU and button_menu -> BTN_VIEW per SWITCH_MAP; here button_escape carries
+ // the 'start' output and button_menu the 'select' output — physical Start/Back pairing is a TODO.)
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_START), b[TritonProtocol.BTN_MENU]?.output)
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_SELECT), b[TritonProtocol.BTN_VIEW]?.output)
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_R1), b[TritonProtocol.BTN_RBUMPER]?.output)
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_L1), b[TritonProtocol.BTN_LBUMPER]?.output)
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_A), b[TritonProtocol.BTN_L4]?.output)
+ assertEquals(gamepadBtn(ExternalController.IDX_BUTTON_X), b[TritonProtocol.BTN_R4]?.output)
+ }
+
+ // ---- v3 schema: controller_xboxone_wasd.vdf (key_press / mouse / staged triggers) ---------------
+
+ @Test
+ fun `v3 wasd config maps keys mouse and staged triggers`() {
+ val p = SteamControllerProfileImporter.import(load("controller_xboxone_wasd.vdf"))
+ val b = p.buttons
+
+ // face buttons -> keys
+ assertEquals(key(XKeycode.KEY_SPACE), b[TritonProtocol.BTN_A]?.output)
+ assertEquals(key(XKeycode.KEY_E), b[TritonProtocol.BTN_B]?.output)
+ assertEquals(key(XKeycode.KEY_R), b[TritonProtocol.BTN_X]?.output)
+ assertEquals(key(XKeycode.KEY_F), b[TritonProtocol.BTN_Y]?.output)
+
+ // switch group: escape/menu + bumpers as scroll wheel + rear paddles
+ assertEquals(key(XKeycode.KEY_ESC), b[TritonProtocol.BTN_MENU]?.output) // button_escape
+ assertEquals(key(XKeycode.KEY_TAB), b[TritonProtocol.BTN_VIEW]?.output) // button_menu
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_SCROLL_DOWN), b[TritonProtocol.BTN_LBUMPER]?.output)
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_SCROLL_UP), b[TritonProtocol.BTN_RBUMPER]?.output)
+ assertEquals(key(XKeycode.KEY_R), b[TritonProtocol.BTN_L5]?.output) // back_left_upper
+ assertEquals(key(XKeycode.KEY_F), b[TritonProtocol.BTN_L4]?.output) // back_left
+ assertEquals(key(XKeycode.KEY_E), b[TritonProtocol.BTN_R5]?.output) // back_right_upper
+ assertEquals(key(XKeycode.KEY_SPACE), b[TritonProtocol.BTN_R4]?.output) // back_right
+
+ // dpad source -> weapon number keys
+ assertEquals(key(XKeycode.KEY_1), b[TritonProtocol.BTN_DPAD_UP]?.output)
+ assertEquals(key(XKeycode.KEY_3), b[TritonProtocol.BTN_DPAD_DOWN]?.output)
+ assertEquals(key(XKeycode.KEY_2), b[TritonProtocol.BTN_DPAD_RIGHT]?.output)
+ assertEquals(key(XKeycode.KEY_4), b[TritonProtocol.BTN_DPAD_LEFT]?.output)
+
+ // joystick source is a WASD d-pad group -> stick-as-dpad (was dropped to None before); its click is kept
+ val ld = p.leftStick as StickMode.DPad
+ assertEquals(key(XKeycode.KEY_W), ld.up); assertEquals(key(XKeycode.KEY_S), ld.down)
+ assertEquals(key(XKeycode.KEY_A), ld.left); assertEquals(key(XKeycode.KEY_D), ld.right)
+ assertEquals(key(XKeycode.KEY_SHIFT_L), b[TritonProtocol.BTN_L3]?.output)
+
+ // right_joystick = joystick_mouse -> StickMode.Mouse (stick drives the pointer), click -> left mouse
+ assertTrue(p.rightStick is StickMode.Mouse)
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_LEFT), b[TritonProtocol.BTN_R3]?.output)
+
+ // triggers: edge digital binding -> Staged full-pull
+ val lt = p.leftTrigger as TriggerMode.Staged
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_RIGHT), lt.full)
+ val rt = p.rightTrigger as TriggerMode.Staged
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_LEFT), rt.full)
+
+ // controller_action (screenshot) is unsupported -> left unbound, not mis-imported
+ assertFalse(b.values.any { it.output is ScOutput.None })
+ }
+
+ // ---- real Triton: chord_triton.vdf (activators, combos, controller_actions skipped) -------------
+
+ @Test
+ fun `triton chord config handles combos activators and skips system actions`() {
+ val p = SteamControllerProfileImporter.import(load("chord_triton.vdf"))
+ val b = p.buttons
+
+ // button_escape Full_Press has two key_press bindings -> a held Alt+Tab combo (Regular activator)
+ val altTab = b[TritonProtocol.BTN_MENU]
+ assertEquals(ScOutput.Key(listOf(XKeycode.KEY_ALT_L, XKeycode.KEY_TAB)), altTab?.output)
+ assertEquals(Activator.Regular, altTab?.activator)
+
+ // face buttons are controller_actions: button_b=quit, button_y=poweroff -> unbound (unsupported);
+ // button_x=SHOW_KEYBOARD -> now bound to ScOutput.ShowKeyboard (toggles the on-screen keyboard).
+ assertFalse(b.containsKey(TritonProtocol.BTN_B))
+ assertEquals(ScOutput.ShowKeyboard, b[TritonProtocol.BTN_X]?.output)
+ assertFalse(b.containsKey(TritonProtocol.BTN_Y))
+ // button_menu -> sr_enable (controller_action) -> Start-side button unbound
+ assertFalse(b.containsKey(TritonProtocol.BTN_VIEW))
+
+ // right_trackpad in absolute_mouse mode -> relative Mouse (Steam's legacy name for the trackpad mouse, NOT a
+ // region map — that's mouse_region); its Soft_Press click -> LMB
+ assertTrue(p.rightPad is PadMode.Mouse)
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_LEFT), b[TritonProtocol.BTN_RPAD_CLICK]?.output)
+
+ // dpad source: gr_clip (action, skipped) + TAB / RETURN / ESCAPE on the other three directions
+ assertFalse(b.containsKey(TritonProtocol.BTN_DPAD_UP)) // gr_clip controller_action
+ assertEquals(key(XKeycode.KEY_TAB), b[TritonProtocol.BTN_DPAD_DOWN]?.output)
+ assertEquals(key(XKeycode.KEY_ENTER), b[TritonProtocol.BTN_DPAD_RIGHT]?.output)
+ assertEquals(key(XKeycode.KEY_ESC), b[TritonProtocol.BTN_DPAD_LEFT]?.output)
+
+ // triggers: edge -> mouse buttons (Staged)
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_RIGHT), (p.leftTrigger as TriggerMode.Staged).full)
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_LEFT), (p.rightTrigger as TriggerMode.Staged).full)
+
+ // joystick = dpad-on-stick but all directions are skipped controller_actions -> empty dpad collapses to None;
+ // right_joystick = joystick_mouse -> StickMode.Mouse
+ assertEquals(StickMode.None, p.leftStick)
+ assertTrue(p.rightStick is StickMode.Mouse)
+ }
+
+ // ---- the real ToME4 community config: "Delf's Sensible Steam Deck Layout" (controller_neptune) ---------
+
+ @Test
+ fun `decodes Delf's real ToME4 Steam Deck community config`() {
+ val p = SteamControllerProfileImporter.import(load("delf_tome4_neptune.vdf"))
+ val b = p.buttons
+ assertTrue("title carried over", p.name.contains("Delf"))
+
+ // base-layer supported parts decode correctly:
+ // face buttons (note: Start_Press activator collapses to Regular)
+ assertEquals(key(XKeycode.KEY_ENTER), b[TritonProtocol.BTN_A]?.output) // RETURN = Confirm
+ assertEquals(Activator.Regular, b[TritonProtocol.BTN_A]?.activator)
+ assertEquals(key(XKeycode.KEY_ESC), b[TritonProtocol.BTN_B]?.output) // ESCAPE = Back/Cancel
+
+ // d-pad -> arrow keys; dpad_north has hold_repeats -> Turbo, dpad_south is plain Regular
+ assertEquals(key(XKeycode.KEY_UP), b[TritonProtocol.BTN_DPAD_UP]?.output)
+ assertTrue("hold_repeats -> Turbo", b[TritonProtocol.BTN_DPAD_UP]?.activator is Activator.Turbo)
+ assertEquals(key(XKeycode.KEY_DOWN), b[TritonProtocol.BTN_DPAD_DOWN]?.output)
+ assertEquals(Activator.Regular, b[TritonProtocol.BTN_DPAD_DOWN]?.activator)
+
+ // triggers: edge -> mouse buttons (Staged); left trigger = right-click (targeting per the guide)
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_RIGHT), (p.leftTrigger as TriggerMode.Staged).full)
+ assertTrue(p.rightTrigger is TriggerMode.Staged)
+
+ // right trackpad = absolute_mouse -> relative Mouse (legacy name for the trackpad mouse; region = mouse_region)
+ assertTrue(p.rightPad is PadMode.Mouse)
+
+ // - left trackpad -> reference -> touch_menu: now imported as a real grid (step-6 selection logic;
+ // overlay HUD still pending). 12 hotkeys 1..= in a 4x3 grid.
+ val leftMenu = p.leftPad as PadMode.TouchMenu
+ assertEquals(12, leftMenu.slots.size)
+ assertEquals(4, leftMenu.cols)
+ assertEquals(3, leftMenu.rows)
+ assertEquals(key(XKeycode.KEY_1), leftMenu.slots.first().binding.output)
+ // - left stick -> reference -> radial_menu ("Movement (Radial)") -> StickMode.RadialMenu (HOLD default).
+ assertTrue(p.leftStick is StickMode.RadialMenu)
+ // - right stick -> dpad-mode group -> stick-as-dpad (was dropped to None before); up/down = mouse-wheel scroll
+ val rd = p.rightStick as StickMode.DPad
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_SCROLL_UP), rd.up)
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_SCROLL_DOWN), rd.down)
+ }
+
+ // ---- importing ALL action sets (foundation for runtime set-switching, step 3) -------------------------
+
+ @Test
+ fun `importActionSets decodes every action set in a config`() {
+ // KSP: 6 game-context action sets (titles are localisation tokens -> keyed by set name)
+ val ksp = SteamControllerProfileImporter.importActionSets(load("ksp_worstcase_xboxone.vdf"))
+ assertEquals(6, ksp.size)
+ assertTrue(ksp.containsKey("FlightControls"))
+ assertTrue(ksp.containsKey("EVAControls"))
+ ksp.values.forEach { assertTrue("each set binds several buttons", it.buttons.size >= 4) }
+
+ // Delf's ToME4: 2 action sets with friendly titles "Main" / "Extra Touch Menus"
+ val delf = SteamControllerProfileImporter.importActionSets(load("delf_tome4_neptune.vdf"))
+ assertEquals(setOf("Main", "Extra Touch Menus"), delf.keys)
+ // the "Main" set matches the default base import (A = RETURN/Confirm)
+ assertEquals(key(XKeycode.KEY_ENTER), delf["Main"]?.buttons?.get(TritonProtocol.BTN_A)?.output)
+
+ // a config with no `actions` block -> a single "Default" entry
+ val sink = SteamControllerProfileImporter.importActionSets(load("kitchensink_v3.vdf"))
+ assertEquals(setOf("Default"), sink.keys)
+ }
+
+ @Test
+ fun `importConfig decodes sets keyed by preset id with CHANGE_PRESET switches`() {
+ val cfg = SteamControllerProfileImporter.importConfig(load("actionsets_v3.vdf"))
+ assertEquals(setOf("0", "1"), cfg.sets.keys)
+ assertEquals("0", cfg.defaultSetId)
+ // Default set (id 0): A -> Q; right_bumper presses-edge switch to set 1
+ assertEquals(key(XKeycode.KEY_Q), cfg.sets["0"]?.buttons?.get(TritonProtocol.BTN_A)?.output)
+ assertEquals(
+ ScOutput.SwitchActionSet("1", onRelease = false),
+ cfg.sets["0"]?.buttons?.get(TritonProtocol.BTN_RBUMPER)?.output,
+ )
+ // Menus set (id 1): A -> M; right_bumper release-edge switch back to set 0
+ assertEquals(key(XKeycode.KEY_M), cfg.sets["1"]?.buttons?.get(TritonProtocol.BTN_A)?.output)
+ assertEquals(
+ ScOutput.SwitchActionSet("0", onRelease = true),
+ cfg.sets["1"]?.buttons?.get(TritonProtocol.BTN_RBUMPER)?.output,
+ )
+ }
+
+ @Test
+ fun `hold_repeats imports as Turbo using repeat_rate as ms not a machine-gun interval`() {
+ // Regression for the d-pad "up jumps straight to fast-repeat" bug: ToME4 binds d-pad up with
+ // hold_repeats (-> Turbo) and a repeat_rate of hundreds of ms. The interval must be that many ms, not
+ // collapsed to the ~10ms floor by mis-reading repeat_rate as a frequency.
+ val cfg = SteamControllerProfileImporter.importConfig(load("delf_tome4_neptune.vdf"))
+ val up = cfg.sets.getValue("0").buttons[TritonProtocol.BTN_DPAD_UP]
+ assertEquals(ScOutput.Key(listOf(XKeycode.KEY_UP)), up?.output)
+ val act = up?.activator
+ assertTrue("d-pad up should import as Turbo (it has hold_repeats)", act is Activator.Turbo)
+ val interval = (act as Activator.Turbo).intervalMs
+ assertTrue("turbo interval ($interval ms) must be a sane repeat, not a ~10ms machine-gun", interval >= 100)
+ }
+
+ @Test
+ fun `importConfig handles the real ToME4 config with no dangling switch targets`() {
+ val cfg = SteamControllerProfileImporter.importConfig(load("delf_tome4_neptune.vdf"))
+ assertEquals(setOf("0", "1"), cfg.sets.keys) // Default + "Extra Touch Menus"
+ assertEquals("0", cfg.defaultSetId)
+ // every CHANGE_PRESET switch we imported resolves to a real set (1-based -> id N-1 mapping is correct)
+ val switches = cfg.sets.values
+ .flatMap { it.buttons.values }
+ .mapNotNull { it.output as? ScOutput.SwitchActionSet }
+ assertTrue("CHANGE_PRESET targets must all resolve", switches.all { cfg.sets.containsKey(it.targetSetId) })
+ }
+
+ @Test
+ fun `importConfig parses layer ops and per-set sources`() {
+ val cfg = SteamControllerProfileImporter.importConfig(load("actionlayers_v3.vdf"))
+ val base = cfg.sets.getValue("0").buttons
+ // add_layer/hold_layer/remove_layer 2 -> layer preset id 1 (1-based)
+ assertEquals(ScOutput.LayerOp("1", LayerOpType.HOLD), base[TritonProtocol.BTN_RBUMPER]?.output)
+ assertEquals(ScOutput.LayerOp("1", LayerOpType.ADD), base[TritonProtocol.BTN_LBUMPER]?.output)
+ assertEquals(ScOutput.LayerOp("1", LayerOpType.REMOVE), base[TritonProtocol.BTN_VIEW]?.output)
+ // the layer (id 1) overrides ONLY the button_diamond source -> partial overlay
+ assertEquals(setOf("button_diamond"), cfg.setSources["1"])
+ }
+
+ @Test
+ fun `importConfig parses mode_shift and decodes its single-source overlay`() {
+ val cfg = SteamControllerProfileImporter.importConfig(load("modeshift_v3.vdf"))
+ // the right_bumper binding is a mode_shift of the button_diamond source to group 1
+ assertEquals(
+ ScOutput.ModeShift("button_diamond", "1"),
+ cfg.sets.getValue("0").buttons[TritonProtocol.BTN_RBUMPER]?.output,
+ )
+ // group 1 decoded as button_diamond -> A becomes M in the overlay
+ assertEquals(key(XKeycode.KEY_M), cfg.shiftOverlays["1"]?.buttons?.get(TritonProtocol.BTN_A)?.output)
+ }
+
+ @Test
+ fun `on-phone engine smoke config decodes all three step-3 mechanisms`() {
+ val cfg = SteamControllerProfileImporter.importConfig(app.gamenative.steamcontroller.SMOKE_CONFIG)
+ assertEquals(setOf("0", "1", "2"), cfg.sets.keys) // Default, Combat, Overlay(layer)
+ assertEquals("0", cfg.defaultSetId)
+ val base = cfg.sets.getValue("0").buttons
+ assertEquals(key(XKeycode.KEY_1), base[TritonProtocol.BTN_A]?.output)
+ assertEquals(ScOutput.LayerOp("2", LayerOpType.HOLD), base[TritonProtocol.BTN_LBUMPER]?.output) // hold_layer 3 -> id 2
+ assertEquals(ScOutput.SwitchActionSet("1"), base[TritonProtocol.BTN_RBUMPER]?.output) // CHANGE_PRESET 2 -> id 1
+ assertEquals(ScOutput.ModeShift("button_diamond", "1"), base[TritonProtocol.BTN_L4]?.output) // back_left
+ assertEquals(key(XKeycode.KEY_3), cfg.sets.getValue("1").buttons[TritonProtocol.BTN_A]?.output) // Combat
+ assertEquals(key(XKeycode.KEY_8), cfg.sets.getValue("2").buttons[TritonProtocol.BTN_A]?.output) // Overlay layer
+ assertEquals(key(XKeycode.KEY_9), cfg.shiftOverlays["1"]?.buttons?.get(TritonProtocol.BTN_A)?.output) // shift target
+ }
+
+ @Test
+ fun `importConfig resolves real KSP layer ops to existing presets`() {
+ val cfg = SteamControllerProfileImporter.importConfig(load("ksp_worstcase_xboxone.vdf"))
+ val layerOps = cfg.sets.values.flatMap { it.buttons.values }.mapNotNull { it.output as? ScOutput.LayerOp }
+ assertTrue("KSP uses layer ops", layerOps.isNotEmpty())
+ // add_layer 7..10 -> layer preset ids 6..9 (1-based); all must resolve to real presets
+ assertTrue("every layer op targets an existing preset", layerOps.all { cfg.sets.containsKey(it.layerId) })
+ }
+
+ // ---- synthetic kitchen-sink: one of EVERY activator + EVERY binding command, with known outputs --------
+
+ @Test
+ fun `kitchen-sink config exercises every activator and binding command`() {
+ val p = SteamControllerProfileImporter.import(load("kitchensink_v3.vdf"))
+ val b = p.buttons
+
+ // ---- every activator type maps correctly ----
+ assertEquals(Activator.Regular, b[TritonProtocol.BTN_A]?.activator) // Full_Press
+ assertTrue(b[TritonProtocol.BTN_B]?.activator is Activator.DoublePress) // Double_Press
+ assertEquals(250L, (b[TritonProtocol.BTN_B]?.activator as Activator.DoublePress).windowMs)
+ assertTrue(b[TritonProtocol.BTN_X]?.activator is Activator.LongPress) // Long_Press
+ assertEquals(600L, (b[TritonProtocol.BTN_X]?.activator as Activator.LongPress).holdMs)
+ assertTrue(b[TritonProtocol.BTN_Y]?.activator is Activator.Turbo) // hold_repeats -> Turbo
+ assertEquals(Activator.Regular, b[TritonProtocol.BTN_MENU]?.activator) // Start_Press -> Regular
+ assertEquals(Activator.OnRelease, b[TritonProtocol.BTN_VIEW]?.activator) // release -> OnRelease (fire on let-go)
+ assertEquals(Activator.Regular, b[TritonProtocol.BTN_LBUMPER]?.activator) // Soft_Press -> Regular
+
+ // ---- every binding command maps correctly ----
+ // key_press combo (two bindings merged) + single key
+ assertEquals(ScOutput.Key(listOf(XKeycode.KEY_CTRL_L, XKeycode.KEY_C)), b[TritonProtocol.BTN_A]?.output)
+ assertEquals(key(XKeycode.KEY_X), b[TritonProtocol.BTN_X]?.output)
+ // mouse_button + mouse_wheel
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_MIDDLE), b[TritonProtocol.BTN_LBUMPER]?.output)
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_SCROLL_UP), b[TritonProtocol.BTN_DPAD_RIGHT]?.output)
+ // xinput_button: face / dpad / stick-click
+ assertEquals(ScOutput.GamepadButton(ExternalController.IDX_BUTTON_A.toInt()), b[TritonProtocol.BTN_L5]?.output)
+ assertEquals(ScOutput.GamepadDpad(0), b[TritonProtocol.BTN_DPAD_UP]?.output)
+ assertEquals(ScOutput.GamepadButton(ExternalController.IDX_BUTTON_L3.toInt()), b[TritonProtocol.BTN_L3]?.output)
+ // key-combo on a d-pad direction
+ assertEquals(ScOutput.Key(listOf(XKeycode.KEY_SHIFT_L, XKeycode.KEY_TAB)), b[TritonProtocol.BTN_DPAD_LEFT]?.output)
+
+ // ---- controller_action (system) + game_action are unsupported -> SKIPPED (not mis-bound) ----
+ assertFalse("controller_action skipped", b.containsKey(TritonProtocol.BTN_RBUMPER))
+ assertFalse("game_action skipped", b.containsKey(TritonProtocol.BTN_L4))
+ // mode_shift is handled (step 3) -> a ModeShift output carrying the raw target group id
+ assertEquals(ScOutput.ModeShift("dpad", "99"), b[TritonProtocol.BTN_R4]?.output)
+ assertFalse("no None leaked", b.values.any { it.output is ScOutput.None })
+
+ // ---- analog sources (incl. per-group settings: deadzone / invert_y / sensitivity) ----
+ // left stick: deadzone 5000 (raw/32768) + invert_y 0
+ assertEquals(StickMode.JoystickMove(Stick.LEFT, invertY = false, deadzone = 5000f / 32768f), p.leftStick)
+ assertEquals(StickMode.JoystickMove(Stick.RIGHT), p.rightStick) // via reference -> group 10 (defaults)
+ assertEquals(ScOutput.GamepadButton(ExternalController.IDX_BUTTON_R3.toInt()), b[TritonProtocol.BTN_R3]?.output)
+ assertTrue(p.leftPad is PadMode.ScrollWheel)
+ // right pad: base absolute_mouse (-> relative Mouse) wins over modeshift dpad; invert_y 0 read from settings
+ val rp = p.rightPad as PadMode.Mouse
+ assertEquals(false, rp.invertY)
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_LEFT), b[TritonProtocol.BTN_RPAD_CLICK]?.output)
+ assertEquals(ScOutput.MouseButton(Pointer.Button.BUTTON_RIGHT), (p.leftTrigger as TriggerMode.Staged).full)
+ assertEquals(TriggerMode.Axis(TriggerAxis.GAMEPAD_R2), p.rightTrigger) // analog xinput TRIGGER_RIGHT
+ }
+
+ // ---- worst case: a 226-group, 10-preset, 13-mode, mode-shift-heavy real config (KSP) -------------------
+
+ @Test
+ fun `worst-case KSP config imports every action set without choking`() {
+ val vdf = load("ksp_worstcase_xboxone.vdf")
+ // 6 game-context action sets, each a separate preset/group_source_bindings over 226 groups.
+ val actionSets = listOf(
+ "MenuControls", "FlightControls", "DockingControls", "EditorControls", "MapControls", "EVAControls",
+ )
+ var sawPadMode = false
+ for (set in actionSets) {
+ val p = SteamControllerProfileImporter.import(vdf, presetName = set)
+ assertTrue("$set: title", p.name.contains("KSP"))
+ // each context binds a real chunk of buttons
+ assertTrue("$set: expected several bound buttons, got ${p.buttons.size}", p.buttons.size >= 4)
+ // robustness: unsupported bindings (mode_shift / controller_action) are skipped, never leaked as None
+ assertFalse("$set: ScOutput.None leaked into buttons", p.buttons.values.any { it.output is ScOutput.None })
+ if (p.leftPad != PadMode.None || p.rightPad != PadMode.None) sawPadMode = true
+ }
+ // the rich analog mode set (absolute_mouse / mouse_region / …) decodes to at least one pad mode somewhere
+ assertTrue("expected at least one pad mode across the action sets", sawPadMode)
+ }
+
+ // ---- mode-shift / action-layer base-layer-wins regression (real community configs use this heavily) ----
+
+ @Test
+ fun `mode-shift layer does not clobber the base binding`() {
+ // right_trackpad has a base group (absolute_mouse) AND a "active modeshift" group (dpad). The base must
+ // win; the mode-shift variant is deferred (action-set/mode-shift feature). Mirrors ToME4 / Dying Light.
+ val vdf = """
+ "controller_mappings"
+ {
+ "group" { "id" "0" "mode" "four_buttons"
+ "inputs" { "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "key_press Q" } } } } } }
+ "group" { "id" "1" "mode" "absolute_mouse"
+ "inputs" { "click" { "activators" { "Full_Press" { "bindings" { "binding" "mouse_button LEFT" } } } } } }
+ "group" { "id" "2" "mode" "dpad"
+ "inputs" { "dpad_north" { "activators" { "Full_Press" { "bindings" { "binding" "key_press W" } } } } } }
+ "preset" { "id" "0" "name" "Default"
+ "group_source_bindings"
+ {
+ "0" "button_diamond active"
+ "1" "right_trackpad active"
+ "2" "right_trackpad active modeshift"
+ } }
+ }
+ """.trimIndent()
+
+ val p = SteamControllerProfileImporter.import(vdf)
+ // base right_trackpad group (absolute_mouse -> relative Mouse) wins, not the mode-shift dpad group
+ assertTrue("base absolute_mouse should win over the modeshift dpad group", p.rightPad is PadMode.Mouse)
+ assertEquals(key(XKeycode.KEY_Q), p.buttons[TritonProtocol.BTN_A]?.output)
+ }
+}
diff --git a/app/src/test/resources/sc/actionlayers_v3.vdf b/app/src/test/resources/sc/actionlayers_v3.vdf
new file mode 100644
index 0000000000..c623d1483f
--- /dev/null
+++ b/app/src/test/resources/sc/actionlayers_v3.vdf
@@ -0,0 +1,32 @@
+// Synthetic action-LAYER config for step-3 layer tests.
+// Base set "Default" (preset id 0): A -> Q, and three layer ops on the bumpers/menu (1-based -> layer id 1):
+// right_bumper = hold_layer 2 (push while held)
+// left_bumper = add_layer 2 (push, persistent)
+// button_menu = remove_layer 2 (pop)
+// Layer "Overlay" (preset id 1, action_layers): rebinds ONLY button_diamond so A -> M. Everything else (incl.
+// the bumper layer-op bindings) falls through to the base set -> proves the partial per-source overlay merge.
+"controller_mappings"
+{
+ "version" "3"
+ "title" "Action Layer (synthetic)"
+ "controller_type" "controller_neptune"
+
+ "actions" { "Default" { "title" "Main" "legacy_set" "1" } }
+ "action_layers" { "Overlay" { "title" "Overlay" "legacy_set" "1" "set_layer" "1" "parent_set_name" "Default" } }
+
+ "group" { "id" "0" "mode" "four_buttons"
+ "inputs" { "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "key_press Q" } } } } } }
+ "group" { "id" "1" "mode" "four_buttons"
+ "inputs" { "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "key_press M" } } } } } }
+ "group" { "id" "2" "mode" "switches"
+ "inputs" {
+ "right_bumper" { "activators" { "Full_Press" { "bindings" { "binding" "controller_action hold_layer 2 0 0" } } } }
+ "left_bumper" { "activators" { "Full_Press" { "bindings" { "binding" "controller_action add_layer 2 0 0" } } } }
+ "button_menu" { "activators" { "Full_Press" { "bindings" { "binding" "controller_action remove_layer 2 0 0" } } } }
+ } }
+
+ "preset" { "id" "0" "name" "Default"
+ "group_source_bindings" { "0" "button_diamond active" "2" "switch active" } }
+ "preset" { "id" "1" "name" "Overlay"
+ "group_source_bindings" { "1" "button_diamond active" } }
+}
diff --git a/app/src/test/resources/sc/actionsets_v3.vdf b/app/src/test/resources/sc/actionsets_v3.vdf
new file mode 100644
index 0000000000..286e14125b
--- /dev/null
+++ b/app/src/test/resources/sc/actionsets_v3.vdf
@@ -0,0 +1,33 @@
+// Synthetic 2-action-set config for the action-set-switching (step 3) tests.
+// "Default" (preset id 0): A -> Q, hold right_bumper (Start_Press) -> CHANGE_PRESET 2 (enter "Menus", id 1).
+// "Menus" (preset id 1): A -> M, release right_bumper -> CHANGE_PRESET 1 (return to "Default", id 0).
+// NB: CHANGE_PRESET is 1-based, so target preset id = N-1 (verified against Delf's real ToME4 config).
+// This is the real "hold for menus" round-trip pattern: enter-on-press in set A, leave-on-release in set B.
+"controller_mappings"
+{
+ "version" "3"
+ "title" "Action Set Switch (synthetic)"
+ "controller_type" "controller_neptune"
+
+ "actions"
+ {
+ "Default" { "title" "Main" "legacy_set" "1" }
+ "Menus" { "title" "Menus" "legacy_set" "1" }
+ }
+
+ "group" { "id" "0" "mode" "four_buttons"
+ "inputs" { "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "key_press Q" } } } } } }
+ "group" { "id" "1" "mode" "four_buttons"
+ "inputs" { "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "key_press M" } } } } } }
+ "group" { "id" "2" "mode" "switches"
+ "inputs" { "right_bumper" { "activators" { "Start_Press" {
+ "bindings" { "binding" "controller_action CHANGE_PRESET 2 0 0, Hold for Menus" } } } } } }
+ "group" { "id" "3" "mode" "switches"
+ "inputs" { "right_bumper" { "activators" { "release" {
+ "bindings" { "binding" "controller_action CHANGE_PRESET 1 0 0, Release to Default" } } } } } }
+
+ "preset" { "id" "0" "name" "Default"
+ "group_source_bindings" { "0" "button_diamond active" "2" "switch active" } }
+ "preset" { "id" "1" "name" "Menus"
+ "group_source_bindings" { "1" "button_diamond active" "3" "switch active" } }
+}
diff --git a/app/src/test/resources/sc/chord_triton.vdf b/app/src/test/resources/sc/chord_triton.vdf
new file mode 100644
index 0000000000..e805cdeccd
--- /dev/null
+++ b/app/src/test/resources/sc/chord_triton.vdf
@@ -0,0 +1,457 @@
+"controller_mappings"
+{
+ "version" "3"
+ "revision" "19"
+ "title" "Steam Button Chord Basic Configuration"
+ "description" ""
+ "creator" "76561197969363440"
+ "controller_type" "controller_triton"
+ "minor_revision" "1"
+ "localization"
+ {
+ "english"
+ {
+ "title" "Steam Button Chord Basic Configuration"
+ "description" "Official Steam Button Chord Basic Configuration"
+ }
+ }
+ "group"
+ {
+ "id" "0"
+ "mode" "four_buttons"
+ "description" ""
+ "inputs"
+ {
+ "button_b"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action quit_application"
+ }
+ "settings"
+ {
+ "long_press_time" "2400"
+ }
+ }
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action empty_binding"
+ }
+ }
+ }
+ }
+ "button_x"
+ {
+ "activators"
+ {
+ "release"
+ {
+ "bindings"
+ {
+ "binding" "controller_action SHOW_KEYBOARD"
+ }
+ }
+ }
+ }
+ "button_y"
+ {
+ "activators"
+ {
+ "full_press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action controller_poweroff"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "4"
+ "mode" "trigger"
+ "description" ""
+ "inputs"
+ {
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "5"
+ "mode" "trigger"
+ "description" ""
+ "inputs"
+ {
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "8"
+ "mode" "absolute_mouse"
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Soft_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "9"
+ "mode" "dpad"
+ "description" ""
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press VOLUME_UP"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press VOLUME_UP"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "20"
+ "haptic_intensity" "0"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press VOLUME_DOWN"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press VOLUME_DOWN"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "20"
+ "haptic_intensity" "0"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ "layout" "3"
+ "deadzone" "12000"
+ }
+ }
+ "group"
+ {
+ "id" "10"
+ "mode" "dpad"
+ "description" ""
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action gr_clip"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press TAB"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RETURN"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press ESCAPE"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ "haptic_intensity_override" "0"
+ }
+ "gameactions"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "11"
+ "mode" "joystick_mouse"
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ }
+ }
+ "group"
+ {
+ "id" "12"
+ "mode" "disabled"
+ "description" ""
+ }
+ "group"
+ {
+ "id" "13"
+ "mode" "disabled"
+ "description" ""
+ }
+ "group"
+ {
+ "id" "7"
+ "mode" "switches"
+ "description" ""
+ "inputs"
+ {
+ "button_escape"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, Alt-Tab"
+ "binding" "key_press TAB, Alt-Tab"
+ }
+ "settings"
+ {
+ "toggle" "1"
+ "interruptable" "0"
+ }
+ }
+ }
+ }
+ "button_menu"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action sr_enable"
+ }
+ }
+ }
+ }
+ "left_bumper"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action toggle_magnifier"
+ }
+ }
+ "release"
+ {
+ "bindings"
+ {
+ "binding" "controller_action toggle_magnifier"
+ }
+ }
+ }
+ }
+ "right_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action SCREENSHOT"
+ }
+ }
+ }
+ }
+ "button_capture"
+ {
+ "activators"
+ {
+ "release"
+ {
+ "bindings"
+ {
+ "binding" "controller_action system_key_1"
+ }
+ }
+ }
+ }
+ "button_back_left"
+ {
+ "activators"
+ {
+ "full_press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action gr_marker"
+ }
+ }
+ }
+ }
+ "button_back_left_upper"
+ {
+ "activators"
+ {
+ "full_press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action gr_toggle"
+ }
+ }
+ }
+ }
+
+ }
+ }
+ "preset"
+ {
+ "id" "0"
+ "name" "Default"
+ "group_source_bindings"
+ {
+ "7" "switch active"
+ "0" "button_diamond active"
+ "8" "right_trackpad active"
+ "9" "joystick active"
+ "4" "left_trigger active"
+ "5" "right_trigger active"
+ "10" "dpad active"
+ "12" "left_trackpad inactive"
+ "11" "right_joystick active"
+ "13" "gyro inactive"
+ }
+ }
+ "settings"
+ {
+ "left_trackpad_mode" "0"
+ "right_trackpad_mode" "0"
+ }
+}
diff --git a/app/src/test/resources/sc/controller_xboxone_wasd.vdf b/app/src/test/resources/sc/controller_xboxone_wasd.vdf
new file mode 100644
index 0000000000..d6e4d75a9d
--- /dev/null
+++ b/app/src/test/resources/sc/controller_xboxone_wasd.vdf
@@ -0,0 +1,824 @@
+"controller_mappings"
+{
+ "version" "3"
+ "game" "WASD with Mouse"
+ "title" "#Title"
+ "description" "#Description"
+ "controller_type" "controller_xboxone"
+ "localization"
+ {
+ "english"
+ {
+ "title" "Keyboard (WASD) and Mouse"
+ "description" "This template works great for the games on Steam that were designed with a keyboard and mouse in mind, without real gamepad support. The controller will drive the game's keyboard based events with buttons, but will make assumptions about which buttons move you around (WASD for movement, space for jump, etc.). The right pad will emulate the movement of a mouse."
+ }
+ "czech"
+ {
+ "title" "Klávesnice (WASD) a myš"
+ "description" "Tato šablona je pro hry ve službě Steam, které byly vytvořeny pro ovládání klávesnicí a myší bez podpory gamepadu. Ovladač nahradí hrou vyžadované klávesy svými tlačítky, avšak pouze odhadne, jaké klávesy jsou použity pro ovládání (WASD pro pohyb, mezerník pro skok, atp.). Pravý trackpad bude emulovat myš."
+ }
+ "danish"
+ {
+ "title" "Tastatur (WASD) og mus"
+ "description" "Denne skabelon virker godt til spil på Steam, der blev designet med tastatur og mus i tankerne og er uden egentlig gamepad-understøttelse. Controlleren vil udføre spillets tastaturbaserede handlinger med knapper, men vil lave antagelser om, hvilke knapper bevæger dig omkring (WASD til bevægelse, mellemrum til at hoppe osv.). Den højre flade vil efterligne en mus' bevægelser."
+ }
+ "dutch"
+ {
+ "title" "Toetsenbord (WASD) en muis"
+ "description" "Deze template werkt goed voor de spellen op Steam die zijn ontworpen voor besturing met een toetsenbord en muis, zonder echte gamepad ondersteuning. De controller bestuurt de toetsenbordgebaseerde gebeurtenissen van het spel met knoppen, maar zal aannames doen over welke knoppen je rondbewegen (WASD voor beweging, spatiebalk voor springen, etc.). De rechter-pad emuleert de beweging van een muis."
+ }
+ "finnish"
+ {
+ "title" "Näppäimistö (WASD) ja hiiri"
+ "description" "Tämä malli sopii hyvin peleille, jotka suunniteltiin näppäimistölle ja hiirelle ilman ohjaintukea. Ohjain omaksuu tietyt näppäimistön näppäimet yleisten toimintojen perusteella (liikkuminen WASD-näppäimillä, hyppääminen välilyönnistä jne.). Oikea ohjainlevy toimii hiirenä."
+ }
+ "french"
+ {
+ "title" "Clavier (ZQSD) et souris"
+ "description" "Ce modèle fonctionne particulièrement bien pour les jeux conçus pour le clavier et la souris, sans support pour la manette. Les actions normalement effectuées au clavier seront déclenchées par les touches du contrôleur. Il sera supposé que le déplacement correspond aux touches ZQSD, le saut à la touche espace, etc. Le pad droit simulera les mouvements de la souris."
+ }
+ "german"
+ {
+ "title" "Tastatur (WASD) und Maus"
+ "description" "Diese Vorlage ist an Steam-Spiele ohne echte Gamepad-Unterstützung angepasst, die eigentlich mithilfe von Tastatur und Maus gesteuert werden. Der Controller ist verantwortlich für die tastenbasierte Steuerung des Spiels, wird aber Annahmen treffen, welche Tasten Sie zur Fortbewegung nutzen (WASD für Bewegungen, Leertaste zum Springen usw.). Das rechte Pad wird die Mausbewegungen emulieren."
+ }
+ "hungarian"
+ {
+ "title" "Billentyűzet (WASD) és egér"
+ "description" "Ez a sablon remekül működik az olyan Steames játékokhoz, melyek billentyűzetet és egeret szem előtt tartva készültek, igazi gamepad-támogatás nélkül. A játékvezérlő a játék billentyűzetalapú eseményeit gombokkal fogja irányítani, de feltételezéseket fog tenni arra vonatkozóan, hogy mely gombok segítségével mozoghatsz (WASD a mozgáshoz, szóköz az ugráshoz, stb.). A jobb felület az egér mozgását fogja emulálni."
+ }
+ "italian"
+ {
+ "title" "Tastiera (WASD) e mouse"
+ }
+ "japanese"
+ {
+ "title" "キーボード (WASD) とマウス"
+ }
+ "koreana"
+ {
+ "title" "키보드(WASD)와 마우스"
+ "description" "이 설정은 게임패드가 아니라 키보드와 마우스의 사용을 염두에 두고 설계된 게임을 플레이할 때 적합합니다. 컨트롤러의 단추가 키보드 입력을 대신하지만, (이동이나 점프 등의) 동작을 할 때 (WASD키나 스페이스 바 등의) 어떤 단추를 썼는지는 예측만 할 뿐입니다. 우측 패드는 마우스 이동을 모방합니다."
+ }
+ "polish"
+ {
+ "title" "Klawiatura (WASD) i mysz"
+ "description" "Ten szablon świetnie działa dla gier na Steam, które zostały zaprojektowane z myślą o klawiaturze i myszce, bez wsparcia dla kontrolera. Kontroler będzie przesyłać do gry zdarzenia klawiatury za pomocą przycisków, ale będzie zgadywać użyte klawisze (WASD dla poruszania się, Spacja dla skoku itd.). Prawy pad będzie emulować poruszanie myszką."
+ }
+ "portuguese"
+ {
+ "title" "Teclado (WASD) e rato"
+ "description" "Este modelo é indicado para jogos no Steam concebidos com teclado e rato em mente, sem compatibilidade com comandos. O comando irá associar as ações das teclas no jogo aos seus botões, e tentará adivinhar que botões te fazem mover (WASD para te deslocares, Espaço para saltar, etc.). O pad direito irá simular o movimento de um rato."
+ }
+ "romanian"
+ {
+ "title" "Tastatură (WASD) și Mouse"
+ "description" "Acest şablon merge grozav cu jocurile de pe Steam care au fost dezvoltate cu o tastatură şi un mouse în minte, fără sprijin nativ pentru controller. Controller-ul va conduce evenimentele bazate pe tastatura jocului cu butoane, dar va face presupuneri bazate pe butoanele care te pun în mişcare (WASD pentru mişcare, space pentru săritură, etc.). Pad-ul din dreapta va simula mişcarea mouse-ului."
+ }
+ "russian"
+ {
+ "title" "Клавиатура (WASD) и мышь"
+ "description" "Этот шаблон подходит для игр в Steam, которые разрабатывались для клавиатуры и мыши без поддержки геймпада. Кнопки контроллера эмулируют клавиши клавиатуры, предугадывая их функции в игре (WASD — передвижение, пробел — прыжок и т.д.). Правый трекпад эмулирует мышь."
+ }
+ "spanish"
+ {
+ "title" "Teclado (WASD) y ratón"
+ "description" "Esta plantilla funciona muy bien con juegos de Steam que fueron diseñados pensando en un teclado y un ratón, sin verdadero soporte para mando. El controlador guiará con los botones los eventos basados en el uso del teclado, pero hará conjeturas acerca de qué botones te mueven (WASD para moverse, espacio para saltar, etc.). El pad derecho emulará el movimiento de un ratón."
+ }
+ "swedish"
+ {
+ "title" "Tangentbord (WASD) och mus"
+ "description" "Denna mall fungerar bra för spel på Steam som främst designats för tangentbord och mus, utan riktigt stöd för gamepad. Handkontrollen tar sig igenom spelets tangentbordsbaserade händelser med knappar, men kommer göra antaganden om vilka knappar som förflyttar dig (WASD för förflyttning, mellanslag för hopp, osv.). Den högra plattan emulerar musens rörelser."
+ }
+ "schinese"
+ {
+ "title" "键盘(WASD)和鼠标"
+ "description" "该模板非常适合 Steam 上原生支持键鼠,却不真正支持手柄的游戏。控制器会通过按键来驱动游戏基于键盘的事件,而且会假定您用哪些按键移动(WSAD 键移动、空格键跳跃等)。右触板会模拟鼠标移动。"
+ }
+ "brazilian"
+ {
+ "title" "Teclado (WASD) e mouse"
+ "description" "Este modelo funciona melhor em jogos Steam projetados para teclado e mouse, sem compatibilidade com controles. O controle usará botões para simular eventos do teclado e assumirá quais botões servem para quais comandos (WASD para movimentação, espaço para pular, etc.). O trackpad direito emulará o movimento de um mouse."
+ }
+ "bulgarian"
+ {
+ "title" "Мишка и клавиатура (WASD)"
+ "description" "Този шаблон върши добра работа при игри, които са били проектирани за мишка и клавиатура, без да имат действителна поддръжка на геймпад. Контролерът ще активира клавиатурно базираните действия чрез бутоните, но ще прави предположения относно това с кои от тях можете да се движите (WASD за движение, интервал за скок и т.н.). Десният пад ще емулира движението на мишката."
+ }
+ "greek"
+ {
+ "title" "Πληκτρολόγιο (WASD) και ποντίκι"
+ "description" "Το πρότυπο αυτό λειτουργεί καλά για παιχνίδια σχεδιασμένα για την χρήση πληκτρολογίου και ποντικιού, χωρίς την υποστήριξη χειριστηρίου. Το χειριστήριο θα οδηγήσει τις ενέργειες του πληκτρολογίου του παιχνιδιού με κουμπιά, αλλά θα κάνει υποθέσεις σχετικά με ποια κουμπιά θα κινείστε (WASD για μετακίνηση, κενό για άλμα, κλπ.). Η δεξιά επιφάνεια θα μιμηθεί την κίνηση του ποντικιού."
+ }
+ "turkish"
+ {
+ "title" "Klavye (WASD) ve Fare"
+ "description" "Bu şablon, gerçek bir kontrolcü desteği olmayan, klavye ve fare düşünülerek tasarlanan Steam'deki oyunlar için çok iyidir. Kontrolcü, oyunun klavye temelli eylemlerini butonlar ile gerçekleştirecektir ancak hangi butonların sizi hareket ettirdiği (hareket için WASD, zıplamak için boşluk, vb.) konusunda varsayımda bulunacaktır. Sağ pad, farenin hareketlerini taklit edecektir."
+ }
+ "ukrainian"
+ {
+ "title" "Клавіатура (WASD) та миша"
+ "description" "Цей шаблон відмінно підійде для ігор у Steam, які розроблялися для клавіатури та миші, без повної підтримки контролера. Контролер емулюватиме значення клавіатурних клавіш для гри, передбачаючи їх дії (клавіші WASD для рухів, пробіл для стрибка тощо). Правий трекпад емулюватиме рух мишею."
+ }
+ }
+ "group"
+ {
+ "id" "0"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_a"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press SPACE, Jump"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ }
+ }
+ }
+ }
+ "button_b"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press E, Use"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ }
+ }
+ }
+ }
+ "button_x"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R, Reload"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ }
+ }
+ }
+ }
+ "button_y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F, Flashlight"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "button_size" "17994"
+ "button_dist" "19994"
+ }
+ }
+ "group"
+ {
+ "id" "1"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 1, Weapon 1"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 3, Weapon 3"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2, Weapon 2"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 4, Weapon 4"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "edge_binding_radius" "24996"
+ }
+ }
+ "group"
+ {
+ "id" "2"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press E, Use"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "sensitivity" "145"
+ "doubetap_max_duration" "320"
+ }
+ "gameactions"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "3"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press W, Move Forward"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press S, Move Back"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press D, Move Right"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press A, Move Left"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, Sprint"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ "edge_binding_radius" "24995"
+ }
+ }
+ "group"
+ {
+ "id" "4"
+ "mode" "trigger"
+ "inputs"
+ {
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "5"
+ "mode" "trigger"
+ "inputs"
+ {
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "7"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 1"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 3"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 4"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "6"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_escape"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press ESCAPE, Menu"
+ }
+ }
+ }
+ }
+ "button_menu"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press TAB, Map"
+ }
+ }
+ }
+ }
+ "left_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN, Previous Weapon"
+ }
+ }
+ }
+ }
+ "right_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP, Next Weapon"
+ }
+ }
+ }
+ }
+ "button_back_left_upper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R, Reload"
+ }
+ }
+ }
+ }
+ "button_back_left"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F, Flashlight"
+ }
+ }
+ }
+ }
+ "button_back_right_upper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press E, Use"
+ }
+ }
+ }
+ }
+ "button_back_right"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press SPACE, Jump"
+ }
+ }
+ }
+ }
+ "button_capture"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action SCREENSHOT"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "9"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ "gameactions"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "10"
+ "mode" "flickstick"
+ "inputs"
+ {
+
+ "click"
+ {
+ "activators"
+ {
+ "Double_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action camera_reset 180 66 90, , "
+ }
+ }
+ }
+ }
+ }
+
+ }
+ "group"
+ {
+ "id" "11"
+ "mode" "flickstick"
+ "inputs"
+ {
+
+ "click"
+ {
+ "activators"
+ {
+ "Double_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action camera_reset 180 66 90, , "
+ }
+ }
+ }
+ }
+ }
+
+ }
+ "group"
+ {
+ "id" "12"
+ "mode" "flickstick"
+ "inputs"
+ {
+
+ "click"
+ {
+ "activators"
+ {
+ "Double_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action camera_reset 180 66 90, , "
+ }
+ }
+ }
+ }
+ }
+
+ }
+ "group"
+ {
+ "id" "13"
+ "mode" "flickstick"
+ "inputs"
+ {
+
+ "click"
+ {
+ "activators"
+ {
+ "Double_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action camera_reset 180 66 90, , "
+ }
+ }
+ }
+ }
+ }
+
+ }
+ "preset"
+ {
+ "id" "0"
+ "name" "Default"
+ "group_source_bindings"
+ {
+ "6" "switch active"
+ "0" "button_diamond active"
+ "1" "left_trackpad inactive"
+ "2" "right_trackpad inactive"
+ "3" "joystick active"
+ "4" "left_trigger active"
+ "5" "right_trigger active"
+ "9" "right_joystick active"
+ "7" "dpad active"
+ "10" "right_joystick inactive"
+ "11" "joystick inactive"
+ "12" "right_trackpad inactive"
+ "13" "left_trackpad inactive"
+ }
+ }
+ "settings"
+ {
+ "left_trackpad_mode" "0"
+ "right_trackpad_mode" "0"
+ }
+}
diff --git a/app/src/test/resources/sc/delf_tome4_neptune.vdf b/app/src/test/resources/sc/delf_tome4_neptune.vdf
new file mode 100644
index 0000000000..02c5dd36b6
--- /dev/null
+++ b/app/src/test/resources/sc/delf_tome4_neptune.vdf
@@ -0,0 +1,3197 @@
+"controller_mappings"
+{
+ "version" "3"
+ "revision" "1457"
+ "title" "Delf's Sensible Steam Deck Layout for Default Controls v3.2"
+ "description" "A sensible and intuitive layout designed to provide access to all necessary binds without overwhelming you. Perfect for new or old players alike. Version 3.2 fixes the annoying layout changed messages and fixes some tooltips."
+ "creator" "76561197968649157"
+ "progenitor" ""
+ "url" "usercloud://259680/delf's sensible steam deck layout for default controls v3.2_0"
+ "export_type" "community"
+ "controller_type" "controller_neptune"
+ "controller_caps" "23117823"
+ "major_revision" "0"
+ "minor_revision" "0"
+ "Timestamp" "-1239531136"
+ "actions"
+ {
+ "Default"
+ {
+ "title" "Main"
+ "legacy_set" "1"
+ }
+ "Preset_1000001"
+ {
+ "title" "Extra Touch Menus"
+ "legacy_set" "1"
+ }
+ }
+ "action_layers"
+ {
+ }
+ "group"
+ {
+ "id" "0"
+ "mode" "four_buttons"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "button_a"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RETURN, Confirm, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_b"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press ESCAPE, Back / Cancel, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_x"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R, Rest, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_y"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Z, Auto-Explore, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "button_size" "17994"
+ "button_dist" "19994"
+ }
+ }
+ "group"
+ {
+ "id" "1"
+ "mode" "dpad"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 1, Weapon 1, , "
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 3, Weapon 3, , "
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2, Weapon 2, , "
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 4, Weapon 4, , "
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "deadzone" "5000"
+ "edge_binding_radius" "24996"
+ }
+ }
+ "group"
+ {
+ "id" "2"
+ "mode" "absolute_mouse"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press L, Free Look, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "sensitivity" "155"
+ "trackball" "0"
+ "friction" "1"
+ "acceleration" "1"
+ "doubetap_max_duration" "320"
+ }
+ }
+ "group"
+ {
+ "id" "3"
+ "mode" "dpad"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW, Move Forward, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "99"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW, Move Back, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "99"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW, Move Right, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "99"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW, Move Left, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "99"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Q, Sprint, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "99"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ "edge_binding_radius" "24995"
+ }
+ }
+ "group"
+ {
+ "id" "4"
+ "mode" "trigger"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT, , "
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "5"
+ "mode" "trigger"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT, , "
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "6"
+ "mode" "scrollwheel"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "7"
+ "mode" "absolute_mouse"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "gyro_natural_sensitivity" "99"
+ }
+ }
+ "group"
+ {
+ "id" "8"
+ "mode" "four_buttons"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "button_a"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_b"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_x"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ }
+ }
+ "group"
+ {
+ "id" "10"
+ "mode" "dpad"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW, Up, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "250"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW, Down, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW, Right, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW, Left, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ "layout" "0"
+ "haptic_intensity_override" "0"
+ }
+ }
+ "group"
+ {
+ "id" "11"
+ "mode" "joystick_mouse"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press C, character, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ "sensitivity" "145"
+ }
+ }
+ "group"
+ {
+ "id" "12"
+ "mode" "four_buttons"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "14"
+ "mode" "scrollwheel"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press C, character, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "scroll_type" "2"
+ }
+ }
+ "group"
+ {
+ "id" "15"
+ "mode" "flickstick"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "16"
+ "mode" "joystick_move"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "17"
+ "mode" "dpad"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP, Scroll Up, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "120"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN, Scroll Down, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "120"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action MOUSE_POSITION 24000 16000 0, Description Scrolling, , "
+ }
+ "settings"
+ {
+ "long_press_time" "120"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action MOUSE_POSITION 8000 16000 0, Description Scrolling, , "
+ }
+ "settings"
+ {
+ "long_press_time" "120"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press X, Toggle Advanced Descriptions, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ "layout" "3"
+ }
+ }
+ "group"
+ {
+ "id" "18"
+ "mode" "touch_menu"
+ "name" "Hotkeys"
+ "description" ""
+ "inputs"
+ {
+ "touch_menu_button_0"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 1, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 3, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 4, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 5, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 6, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_6"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 7, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_7"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 8, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_8"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 9, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_9"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 0, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_10"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DASH, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_11"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press EQUALS, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "touch_menu_opacity" "100"
+ "touch_menu_position_x" "5"
+ "touch_menu_position_y" "62"
+ "touchmenu_inner_deadzone" "0"
+ "touchmenu_button_fire_type" "1"
+ }
+ }
+ "group"
+ {
+ "id" "20"
+ "mode" "radial_menu"
+ "name" "Menus"
+ "description" ""
+ "inputs"
+ {
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press P, Level Up, ghost_035_magic_0316.png, #232323 #48B119"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "500"
+ "repeat_rate" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press C, Character, ghost_050_menu_0040.png, #232323 #00ADAD"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "500"
+ "repeat_rate" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press J, Journal, ghost_050_menu_0090.png, #232323 #AD00AD"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "500"
+ "repeat_rate" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press M, Learned Abilities, ghost_035_magic_0318.png, #232323 #AD3A00"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "500"
+ "repeat_rate" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press H, Combat/Message Log, ghost_075_utility_030.png, #232323 #AD0000"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "500"
+ "repeat_rate" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_6"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action SHOW_KEYBOARD, Show Keyboard, , "
+ "binding" "controller_action CHANGE_PRESET 1 0 0, Show Keyboard, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "500"
+ "repeat_rate" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_7"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, Landmarks (Mod Required), ghost_030_inv_0303.png, #232323 #33AD69"
+ "binding" "key_press LEFT_ALT, Landmarks (Mod Required), ghost_030_inv_0303.png, #232323 #33AD69"
+ "binding" "key_press L, Landmarks (Mod Required), ghost_030_inv_0303.png, #232323 #33AD69"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "500"
+ "repeat_rate" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_8"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press TAB, Map, ghost_050_menu_0030.png, #232323 #0074AD"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "500"
+ "repeat_rate" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_9"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press I, Inventory, ghost_030_inv_0070.png, #232323 #96AD00"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "500"
+ "repeat_rate" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "touchmenu_button_fire_type" "3"
+ "touch_menu_opacity" "100"
+ "touch_menu_position_x" "5"
+ "touch_menu_position_y" "58"
+ "touch_menu_scale" "130"
+ }
+ }
+ "group"
+ {
+ "id" "21"
+ "mode" "radial_menu"
+ "name" "Movement (Radial)"
+ "description" ""
+ "inputs"
+ {
+ "touch_menu_button_0"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action mouse_delta 0 0, , ghost_035_magic_0311.png, #232323 #FFFFFF"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW, , ghost_045_move_0407.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "200"
+ "repeat_rate" "280"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_9, , ghost_045_move_0404.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "200"
+ "repeat_rate" "280"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW, , ghost_045_move_0400.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "200"
+ "repeat_rate" "280"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_3, , ghost_045_move_0402.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "200"
+ "repeat_rate" "280"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW, , ghost_045_move_0403.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "200"
+ "repeat_rate" "280"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_6"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_1, , ghost_045_move_0406.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "200"
+ "repeat_rate" "280"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_7"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW, , ghost_045_move_0401.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "200"
+ "repeat_rate" "280"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_8"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_7, , ghost_045_move_0405.png, #232323 #FFFFFF"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "200"
+ "repeat_rate" "280"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_5, Wait, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "280"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "touchmenu_button_fire_type" "3"
+ "haptic_intensity" "3"
+ "touch_menu_button_count" "9"
+ "touch_menu_opacity" "100"
+ "touch_menu_position_x" "5"
+ "touch_menu_position_y" "62"
+ "touch_menu_show_labels" "0"
+ }
+ }
+ "group"
+ {
+ "id" "22"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "18"
+ }
+ }
+ "group"
+ {
+ "id" "23"
+ "mode" "dpad"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "24"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "21"
+ }
+ }
+ "group"
+ {
+ "id" "25"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "21"
+ }
+ }
+ "group"
+ {
+ "id" "32"
+ "mode" "flickstick"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "33"
+ "mode" "joystick_move"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "34"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "18"
+ }
+ }
+ "group"
+ {
+ "id" "36"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "20"
+ }
+ }
+ "group"
+ {
+ "id" "53"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "18"
+ }
+ }
+ "group"
+ {
+ "id" "52"
+ "mode" "four_buttons"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "button_a"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RETURN, Confirm, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_b"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press ESCAPE, Back / Cancel, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "button_size" "17994"
+ "button_dist" "19994"
+ }
+ }
+ "group"
+ {
+ "id" "54"
+ "mode" "absolute_mouse"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Soft_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press L, Free Look, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "sensitivity" "145"
+ "friction" "1"
+ "acceleration" "1"
+ "doubetap_max_duration" "320"
+ }
+ }
+ "group"
+ {
+ "id" "55"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "21"
+ }
+ }
+ "group"
+ {
+ "id" "56"
+ "mode" "trigger"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT, , "
+ }
+ "settings"
+ {
+ "repeat_rate" "99"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "57"
+ "mode" "trigger"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "58"
+ "mode" "dpad"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW, Up, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW, Down, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW, Right, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW, Left, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ "layout" "0"
+ "haptic_intensity_override" "0"
+ }
+ }
+ "group"
+ {
+ "id" "59"
+ "mode" "dpad"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP, Scroll Up, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "10"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN, Scroll Down, , "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "10"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action MOUSE_POSITION 24000 16000 0, Description Scrolling, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action MOUSE_POSITION 8000 16000 0, Description Scrolling, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ "layout" "3"
+ }
+ }
+ "group"
+ {
+ "id" "60"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "20"
+ }
+ }
+ "group"
+ {
+ "id" "61"
+ "mode" "radial_menu"
+ "name" "Party"
+ "description" ""
+ "inputs"
+ {
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F1, You, ghost_040_act_0312.png, #232323 #96AD00"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "500"
+ "repeat_rate" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F2, Party 2, ghost_035_magic_0345.png, #232323 #0045AD"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "500"
+ "repeat_rate" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F3, Party 3, ghost_035_magic_0343.png, #232323 #00AD00"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "500"
+ "repeat_rate" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F4, Party 4, ghost_035_magic_0331.png, #232323 #AD0000"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "500"
+ "repeat_rate" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F5, Party 5, ghost_035_magic_0365.png, #232323 #AD5D00"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "long_press_time" "500"
+ "repeat_rate" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "touchmenu_button_fire_type" "3"
+ "haptic_intensity" "3"
+ "touch_menu_button_count" "2"
+ "touch_menu_opacity" "100"
+ "touch_menu_position_x" "95"
+ "touch_menu_position_y" "62"
+ "touch_menu_scale" "110"
+ }
+ }
+ "group"
+ {
+ "id" "62"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "61"
+ }
+ }
+ "group"
+ {
+ "id" "63"
+ "mode" "touch_menu"
+ "name" "Actions"
+ "description" ""
+ "inputs"
+ {
+ "touch_menu_button_0"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press PAGE_UP, Previous Hotbar, ghost_045_move_0230.png, #232323 #00ADAD"
+ }
+ "settings"
+ {
+ "repeat_rate" "240"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press PAGE_DOWN, Next Hotbar, ghost_045_move_0210.png, #232323 #00ADAD"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, Restart Sustains (Mod Required), ghost_075_utility_040.png, #232323 #96AD00"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_end" "50"
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press S, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_start" "10"
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_end" "50"
+ "interruptable" "0"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, Change Level, ghost_045_move_0120.png, #232323 #6800AD"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_end" "50"
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press COMMA, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_start" "10"
+ "interruptable" "0"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL, Toggle Automatic Talents, ghost_050_menu_0303.png, #232323 #AD5D00"
+ }
+ "settings"
+ {
+ "repeat_rate" "1000"
+ "haptic_intensity" "2"
+ "delay_end" "50"
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press P, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_start" "10"
+ "interruptable" "0"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press G, Pickup Item, ghost_045_move_0020.png, #232323 #00AD00"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_6"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, Drop Item, ghost_045_move_0010.png, #232323 #AD0000"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_end" "50"
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press D, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_start" "10"
+ "interruptable" "0"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "touch_menu_opacity" "100"
+ "touch_menu_position_x" "5"
+ "touch_menu_position_y" "62"
+ }
+ }
+ "group"
+ {
+ "id" "64"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "63"
+ }
+ }
+ "group"
+ {
+ "id" "65"
+ "mode" "touch_menu"
+ "name" "Movement"
+ "description" ""
+ "inputs"
+ {
+ "touch_menu_button_0"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_7, , ghost_045_move_0405.png, #232323 #FFFFFF"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "250"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW, , ghost_045_move_0407.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "250"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_9, , ghost_045_move_0404.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "250"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW, , ghost_045_move_0401.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "250"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_5, , ghost_035_magic_0311.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "250"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW, , ghost_045_move_0400.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "250"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_6"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_1, , ghost_045_move_0406.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "250"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_7"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW, , ghost_045_move_0403.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "250"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_8"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_3, , ghost_045_move_0402.png, "
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "250"
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "haptic_intensity" "0"
+ "touch_menu_button_count" "9"
+ "touch_menu_opacity" "100"
+ "touch_menu_position_x" "5"
+ "touch_menu_position_y" "62"
+ "touch_menu_show_labels" "0"
+ }
+ }
+ "group"
+ {
+ "id" "66"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "65"
+ }
+ }
+ "group"
+ {
+ "id" "67"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "63"
+ }
+ }
+ "group"
+ {
+ "id" "68"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "63"
+ }
+ }
+ "group"
+ {
+ "id" "69"
+ "mode" "trigger"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "70"
+ "mode" "trigger"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "71"
+ "mode" "touch_menu"
+ "name" "Uncommon Actions"
+ "description" ""
+ "inputs"
+ {
+ "touch_menu_button_0"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, Toggle UI Visibility, ghost_090_media_0020.png, #232323 #33AD69"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_end" "50"
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press W, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_start" "10"
+ "interruptable" "0"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, Tactical Display, ghost_050_menu_0060.png, #232323 #74AD00"
+ }
+ "settings"
+ {
+ "repeat_rate" "1000"
+ "haptic_intensity" "2"
+ "delay_end" "50"
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press T, , "
+ }
+ "settings"
+ {
+ "repeat_rate" "1000"
+ "haptic_intensity" "2"
+ "delay_start" "10"
+ "interruptable" "0"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press SPACE, Chat, ghost_110_social_0150.png, "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press P, Cleanup Particles (Mod Required), ghost_035_magic_0312.png, #232323 #0074AD"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_start" "10"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_end" "50"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_end" "50"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_end" "50"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Y, Store (MTX), ghost_050_menu_0010.png, #232323 #AD3A00"
+ }
+ "settings"
+ {
+ "repeat_rate" "1000"
+ "haptic_intensity" "2"
+ "delay_start" "10"
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL, , "
+ }
+ "settings"
+ {
+ "repeat_rate" "1000"
+ "haptic_intensity" "2"
+ "delay_end" "50"
+ "interruptable" "0"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, Cosmetics, ghost_050_menu_0120.png, #232323 #AD00AD"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_end" "50"
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Y, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_start" "10"
+ "interruptable" "0"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "sensitivity" "300"
+ "touch_menu_opacity" "100"
+ "touch_menu_position_x" "95"
+ "touch_menu_position_y" "62"
+ }
+ }
+ "group"
+ {
+ "id" "72"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "71"
+ }
+ }
+ "group"
+ {
+ "id" "51"
+ "mode" "switches"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "button_escape"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press ESCAPE, Menu, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "left_bumper"
+ {
+ "activators"
+ {
+ "release"
+ {
+ "bindings"
+ {
+ "binding" "controller_action CHANGE_PRESET 1 0 0, Release to Default, , "
+ }
+ "settings"
+ {
+ "delay_start" "500"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "right_bumper"
+ {
+ "activators"
+ {
+ "release"
+ {
+ "bindings"
+ {
+ "binding" "controller_action CHANGE_PRESET 1 0 0, Release to Default, , "
+ }
+ "settings"
+ {
+ "delay_start" "500"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_back_right_upper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL, Hold for Party Commands, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_capture"
+ {
+ "activators"
+ {
+ "release"
+ {
+ "bindings"
+ {
+ "binding" "controller_action system_key_1, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "9"
+ "mode" "switches"
+ "name" ""
+ "description" "Just what you need, with extended play in mind."
+ "inputs"
+ {
+ "button_escape"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press ESCAPE, Menu, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_menu"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Q, Weapon Swap, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "left_bumper"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action CHANGE_PRESET 2 0 0, Hold for Menus, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "right_bumper"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action CHANGE_PRESET 2 0 0, Hold for Menus, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_back_left"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, Change Level, , "
+ "binding" "key_press PERIOD, Change Level, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_back_right"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, Hotbar, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_back_left_upper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, Hotbar, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_back_right_upper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL, Hotbar, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_capture"
+ {
+ "activators"
+ {
+ "release"
+ {
+ "bindings"
+ {
+ "binding" "controller_action system_key_1, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ }
+ "preset"
+ {
+ "id" "0"
+ "name" "Default"
+ "group_source_bindings"
+ {
+ "9" "switch active"
+ "0" "button_diamond active"
+ "1" "left_trackpad inactive"
+ "6" "left_trackpad inactive"
+ "12" "left_trackpad inactive"
+ "22" "left_trackpad active"
+ "23" "left_trackpad inactive modeshift"
+ "2" "right_trackpad active"
+ "3" "joystick inactive"
+ "8" "joystick inactive"
+ "18" "joystick inactive"
+ "32" "joystick inactive"
+ "33" "joystick inactive"
+ "34" "joystick inactive"
+ "21" "joystick inactive"
+ "25" "joystick active"
+ "65" "joystick inactive"
+ "20" "joystick inactive"
+ "36" "joystick inactive"
+ "61" "joystick inactive"
+ "71" "joystick inactive"
+ "4" "left_trigger active"
+ "5" "right_trigger active"
+ "7" "gyro inactive"
+ "10" "dpad active"
+ "11" "right_joystick inactive"
+ "14" "right_joystick inactive"
+ "15" "right_joystick inactive"
+ "16" "right_joystick inactive"
+ "17" "right_joystick active"
+ "24" "right_joystick inactive"
+ }
+ }
+ "preset"
+ {
+ "id" "1"
+ "name" "Preset_1000001"
+ "group_source_bindings"
+ {
+ "51" "switch active"
+ "52" "button_diamond active"
+ "53" "left_trackpad inactive"
+ "64" "left_trackpad active"
+ "66" "left_trackpad inactive"
+ "54" "right_trackpad inactive"
+ "68" "right_trackpad inactive"
+ "72" "right_trackpad active"
+ "55" "joystick inactive"
+ "63" "joystick inactive"
+ "60" "joystick active"
+ "56" "left_trigger inactive"
+ "69" "left_trigger active"
+ "57" "right_trigger inactive"
+ "70" "right_trigger active"
+ "58" "dpad active"
+ "59" "right_joystick inactive"
+ "67" "right_joystick inactive"
+ "62" "right_joystick active"
+ }
+ }
+ "settings"
+ {
+ }
+}
diff --git a/app/src/test/resources/sc/doom_sc_test.vdf b/app/src/test/resources/sc/doom_sc_test.vdf
new file mode 100644
index 0000000000..e9bff3cdd9
--- /dev/null
+++ b/app/src/test/resources/sc/doom_sc_test.vdf
@@ -0,0 +1,117 @@
+"controller_mappings"
+{
+ "version" "3"
+ "title" "DOOM + DOOM II — SC Test"
+ "description" "Gamepad + gyro-camera + weapon-swipe layout for testing the Steam Controller in DOOM (KEX)."
+ "controller_type" "controller_triton"
+ "group"
+ {
+ "id" "0"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button A" } } } }
+ "button_b" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button B" } } } }
+ "button_x" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button X" } } } }
+ "button_y" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button Y" } } } }
+ }
+ }
+ "group"
+ {
+ "id" "1"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ "click" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button JOYSTICK_LEFT" } } } }
+ }
+ "settings" { "deadzone_inner_radius" "3500" }
+ }
+ "group"
+ {
+ "id" "2"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ "click" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button JOYSTICK_RIGHT" } } } }
+ }
+ "settings" { "deadzone_inner_radius" "3500" }
+ }
+ "group"
+ {
+ "id" "3"
+ "mode" "trigger"
+ "inputs" {}
+ "settings" { "output_trigger" "1" }
+ }
+ "group"
+ {
+ "id" "4"
+ "mode" "trigger"
+ "inputs" {}
+ "settings" { "output_trigger" "2" }
+ }
+ "group"
+ {
+ "id" "5"
+ "mode" "2dscroll"
+ "inputs"
+ {
+ "dpad_north" { "activators" { "Full_Press" { "bindings" { "binding" "mouse_wheel SCROLL_UP" } } } }
+ "dpad_south" { "activators" { "Full_Press" { "bindings" { "binding" "mouse_wheel SCROLL_DOWN" } } } }
+ "dpad_west" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button DPAD_LEFT" } } } }
+ "dpad_east" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button DPAD_RIGHT" } } } }
+ "click" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SELECT" } } } }
+ }
+ "settings" { "sensitivity" "100" }
+ }
+ "group"
+ {
+ "id" "6"
+ "mode" "mouse"
+ "inputs"
+ {
+ "click" { "activators" { "Full_Press" { "bindings" { "binding" "mouse_button LEFT" } } } }
+ }
+ "settings" { "sensitivity" "120" }
+ }
+ "group"
+ {
+ "id" "7"
+ "mode" "switches"
+ "inputs"
+ {
+ "left_bumper" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SHOULDER_LEFT" } } } }
+ "right_bumper" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SHOULDER_RIGHT" } } } }
+ "button_menu" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button START" } } } }
+ "button_escape" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SELECT" } } } }
+ "button_back_left" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button A" } } } }
+ "button_back_right" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button B" } } } }
+ "button_back_left_upper" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SHOULDER_LEFT" } } } }
+ "button_back_right_upper" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SHOULDER_RIGHT" } } } }
+ }
+ }
+ "group"
+ {
+ "id" "8"
+ "mode" "gyro_to_joystick"
+ "inputs" {}
+ "settings" { "output_joystick" "2" }
+ }
+ "preset"
+ {
+ "id" "0"
+ "name" "Default"
+ "group_source_bindings"
+ {
+ "0" "button_diamond active"
+ "1" "joystick active"
+ "2" "right_joystick active"
+ "3" "left_trigger active"
+ "4" "right_trigger active"
+ "5" "left_trackpad active"
+ "6" "right_trackpad active"
+ "7" "switch active"
+ "8" "gyro active"
+ }
+ }
+}
diff --git a/app/src/test/resources/sc/gamepad_joystick.vdf b/app/src/test/resources/sc/gamepad_joystick.vdf
new file mode 100644
index 0000000000..c6282debdc
--- /dev/null
+++ b/app/src/test/resources/sc/gamepad_joystick.vdf
@@ -0,0 +1,254 @@
+"controller_mappings"
+{
+ "version" "2"
+ "game" "Gamepad with Joystick Camera"
+ "title" "#Title"
+ "description" "#Description"
+
+ "group"
+ {
+ "id" "0"
+ "mode" "four_buttons"
+ "bindings"
+ {
+ "button_a" "xinput_button A"
+ "button_b" "xinput_button B"
+ "button_x" "xinput_button X"
+ "button_y" "xinput_button Y"
+ }
+ }
+ "group"
+ {
+ "id" "1"
+ "mode" "dpad"
+ "bindings"
+ {
+ "dpad_north" "xinput_button dpad_up"
+ "dpad_south" "xinput_button dpad_down"
+ "dpad_east" "xinput_button dpad_right"
+ "dpad_west" "xinput_button dpad_left"
+ }
+ "settings"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "2"
+ "mode" "joystick_move"
+ "bindings"
+ {
+ "click" "xinput_button JOYSTICK_RIGHT"
+ }
+ "settings"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "3"
+ "mode" "joystick_move"
+ "bindings"
+ {
+ "click" "xinput_button JOYSTICK_LEFT"
+ }
+ "settings"
+ {
+ "adaptive_centering" "0"
+ }
+ }
+ "group"
+ {
+ "id" "4"
+ "mode" "trigger"
+ "bindings"
+ {
+ "click" "xinput_button TRIGGER_LEFT"
+ }
+ "settings"
+ {
+ "output_trigger" "1"
+ }
+ }
+ "group"
+ {
+ "id" "5"
+ "mode" "trigger"
+ "bindings"
+ {
+ "click" "xinput_button TRIGGER_RIGHT"
+ }
+ "settings"
+ {
+ "output_trigger" "2"
+ }
+ }
+ "group"
+ {
+ "id" "6"
+ "mode" "joystick_move"
+ "bindings"
+ {
+ "click" "xinput_button JOYSTICK_RIGHT"
+ }
+ "settings"
+ {
+ "output_joystick" "1"
+ }
+ }
+ "group_source_bindings"
+ {
+ "0" "button_diamond active"
+ "1" "left_trackpad active"
+ "2" "right_trackpad active"
+ "3" "joystick active"
+ "4" "left_trigger active"
+ "5" "right_trigger active"
+ "6" "right_trackpad inactive"
+ }
+ "switch_bindings"
+ {
+ "bindings"
+ {
+ "button_escape" "xinput_button start"
+ "button_menu" "xinput_button select"
+ "right_bumper" "xinput_button shoulder_right"
+ "left_bumper" "xinput_button shoulder_left"
+ "button_back_left" "xinput_button a"
+ "button_back_right" "xinput_button x"
+ }
+ }
+ "settings"
+ {
+ "left_trackpad_mode" "0"
+ "right_trackpad_mode" "0"
+ }
+ "Localization"
+ {
+ "english"
+ {
+ "Title" "Gamepad"
+ "Description" "This template is for games that already have built-in gamepad support. Intended for dual stick games such as twin-stick shooters, side-scrollers, etc."
+ }
+ "czech"
+ {
+ "Title" "Gamepad"
+ "Description" "Tato šablona je pro většinu her podporujících gamepad a byla navržena pro použití ve hrách využívajících dvě páčky, jakými jsou například plošinovky nebo automatové hry."
+ }
+ "danish"
+ {
+ "Title" "Gamepad"
+ "Description" "Denne skabelon er til spil, der allerede har indbygget gamepad-understøttelse. Beregnet til spil med dobbelte styrepinde såsom twin-stick shooters, side-scrollers osv."
+ }
+ "dutch"
+ {
+ "Title" "Gamepad"
+ "Description" "Dit sjabloon is geschikt voor de meeste spellen die al ondersteuning voor gamepads ingebouwd hebben. Bedoeld voor dualstick-spellen zoals twin-stick-shooters, side-scrollers, etc."
+ }
+ "finnish"
+ {
+ "Title" "Ohjain"
+ "Description" "Tämä malli on muita ohjaimia valmiiksi tukeville peleille. Se on tarkoitettu kahta sauvaa käyttäville peleille, kuten twin-stick shooterit, side-scrollerit, jne."
+ }
+ "french"
+ {
+ "Title" "Manette"
+ "Description" "Ce modèle fonctionne pour les jeux conçus pour manettes à deux sticks tels que les jeux de type twin-stick shooter, à défilement horizontal (side-scrollers), etc."
+ }
+ "german"
+ {
+ "Title" "Gamepad"
+ "Description" "Diese Vorlage ist für Spiele konzipiert, die bereits volle Unterstützung für Gamepads mit sich bringen. Gedacht für Zwei-Analogstick-Spiele wie Twin-Stick-Shooter, Side-Scrollers usw."
+ }
+ "hungarian"
+ {
+ "Title" "Gamepad"
+ "Description" "Ez a sablon olyan játékokhoz való, melyek már rendelkeznek beépített gamepad-támogatással. Olyan két karos játékokhoz szánva, mint a kétkaros vagy oldalnézetes lövöldözős játékok stb."
+ }
+ "italian"
+ {
+ "Title" "Controller"
+ "Description" "Questo modello funziona per i giochi che supportano i controller in modalità nativa. È stato pensato per i giochi che utilizzano due stick, come gli sparatutto twin-stick, quelli a scorrimento orizzontale e così via."
+ }
+ "japanese"
+ {
+ "Title" "ゲームパッド"
+ "Description" "このテンプレートは、標準でゲームパッドをサポートしているツインスティックシューターや横スクロール等といったデュアルスティックゲームを対象としたゲーム向けです。"
+ }
+ "koreana"
+ {
+ "Title" "게임패드"
+ "Description" "이 설정은 게임 패드를 지원이 내장된 게임들을 위한 것으로 이중 스틱 슈팅 게임, 사이드 스크롤 게임 등 스틱을 두 개 쓰는 게임을 염두에 두고 만들어졌습니다."
+ }
+ "polish"
+ {
+ "Title" "Kontroler"
+ "Description" "Ten szablon jest odpowiedni dla gier, które już mają wbudowane wsparcie dla kontrolerów. Przeznaczony dla gier obsługujących dwa drążki, m.in. twin-stick shootery, side-scrollery itp."
+ }
+ "portuguese"
+ {
+ "Title" "Comando"
+ "Description" "Este modelo é indicado para jogos que já têm compatibilidade nativa com comando. Foi concebido para jogos de tiros que usam dois sticks, jogos de plataformas, de naves, etc."
+ }
+ "romanian"
+ {
+ "Title" "Gamepad"
+ "Description" "Șablonul acesta este pentru jocurile care au deja suport implementat pentru gamepad. Destinat pentru jocuri dual stick, precum shootere twin-stick, side-scroller, etc."
+ }
+ "russian"
+ {
+ "Title" "Геймпад"
+ "Description" "Этот шаблон подходит для большинства игр с поддержкой геймпада — например, для шутеров с видом сверху или сбоку."
+ }
+ "spanish"
+ {
+ "Title" "Mando"
+ "Description" "Esta plantilla es para juegos que ya incluyen de serie compatibilidad con mando. Está destinada a juegos de doble stick como twin-stick shooters, side-scrollers, etc."
+ }
+ "swedish"
+ {
+ "Title" "Gamepad"
+ "Description" "Denna mall är för spel som redan har inbyggt stöd för spelkontroller. Avsett för spel som använder två styrspakar, som twin-stick shooters och side-scrollers, etc."
+ }
+ "schinese"
+ {
+ "Title" "手柄"
+ "Description" "该模板适用于已内置手柄支持的游戏。针对双摇杆游戏,如双摇杆射击游戏、横版过关游戏等设计。"
+ }
+ "tchinese"
+ {
+ "Title" "遊戲手把"
+ }
+ "thai"
+ {
+ "Title" "เกมแพด"
+ "Description" "แม่แบบนี้ใช้สำหรับเกมที่มีการรองรับเกมแพดมาในตัวอยู่แล้ว เหมาะสำหรับเกมแบบสติกคู่ เช่น เกมยิงแบบสติกคู่ เกมแบบเลื่อนฉากด้านข้าง ฯลฯ"
+ }
+ "brazilian"
+ {
+ "Title" "Controle padrão"
+ "Description" "Este modelo é para jogos já compatíveis com controle que usam ambas as alavancas, como jogos de nave, etc."
+ }
+ "bulgarian"
+ {
+ "Title" "Геймпад"
+ "Description" "Този шаблон е за игри, които вече имат вградена поддръжка на геймпад. Предназначен e за игри ползващи двата стика. Като например, екшъни за два аналогови стика, странични скролери и т.н."
+ }
+ "greek"
+ {
+ "Title" "Χειριστήριο"
+ "Description" "Αυτό το πρότυπο ορίζεται για παιχνίδια τα οποία έχουν ήδη υποστήριξη χειριστηρίου. Προορίζεται για παιχνίδια dual-stick όπως twin-stick shooters, side-scrollers, κλπ."
+ }
+ "turkish"
+ {
+ "Title" "Oyun Kumandası"
+ "Description" "Bu şablon hali hazırda oyun içi oyun kumandası desteği ve birincil veya üçüncü kişi kontrollü kameraya sahip oyunlar içindir. Çift çubuk kullanılan oyunlar olan ikiz çubuk nişancılık, side-scroller oyunlar vb. içindir."
+ }
+ "ukrainian"
+ {
+ "Title" "Ґеймпад"
+ "Description" "Цей шаблон для більшості ігор, в яких вже вбудовано підтримку ґеймпада. Призначено для ігор з керуванням двома стіками."
+ }
+ }
+}
+
diff --git a/app/src/test/resources/sc/golden_ble_001.bin b/app/src/test/resources/sc/golden_ble_001.bin
new file mode 100644
index 0000000000..71d73a53a2
Binary files /dev/null and b/app/src/test/resources/sc/golden_ble_001.bin differ
diff --git a/app/src/test/resources/sc/kitchensink_v3.vdf b/app/src/test/resources/sc/kitchensink_v3.vdf
new file mode 100644
index 0000000000..5e3eadb15e
--- /dev/null
+++ b/app/src/test/resources/sc/kitchensink_v3.vdf
@@ -0,0 +1,158 @@
+// Synthetic "kitchen-sink" Steam Input config (v3 schema) authored for the importer worst-case test.
+// Goal: exercise ONE OF EVERY activator and EVERY binding command in a single config, with known outputs —
+// something no real-world config does (real configs are big but feature-narrow, e.g. KSP is ~all key_press).
+// Feature surface mirrored from a scan of 358 local community configs (docs/COMMUNITY-CONFIG-IMPORT.md).
+"controller_mappings"
+{
+ "version" "3"
+ "title" "Kitchen Sink (synthetic worst case)"
+ "controller_type" "controller_neptune"
+
+ // --- button_diamond: all four "core" activators incl. Turbo + a key combo ---
+ "group"
+ {
+ "id" "0"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_a" { "activators" { "Full_Press" { "bindings" {
+ "binding" "key_press LEFT_CONTROL, Copy"
+ "binding" "key_press C, Copy"
+ } } } }
+ "button_b" { "activators" { "Double_Press" {
+ "bindings" { "binding" "key_press V, Paste" }
+ "settings" { "doubletap_max_duration" "250" }
+ } } }
+ "button_x" { "activators" { "Long_Press" {
+ "bindings" { "binding" "key_press X" }
+ "settings" { "long_press_time" "600" }
+ } } }
+ "button_y" { "activators" { "Full_Press" {
+ "bindings" { "binding" "key_press Y" }
+ "settings" { "hold_repeats" "1" "repeat_rate" "10" }
+ } } }
+ }
+ }
+
+ // --- switch: Start_Press / release / Soft_Press + the three SKIPPED binding commands ---
+ "group"
+ {
+ "id" "1"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_escape" { "activators" { "Start_Press" { "bindings" { "binding" "key_press ESCAPE" } } } }
+ "button_menu" { "activators" { "release" { "bindings" { "binding" "key_press TAB" } } } }
+ "left_bumper" { "activators" { "Soft_Press" { "bindings" { "binding" "mouse_button MIDDLE" } } } }
+ "right_bumper" { "activators" { "Full_Press" { "bindings" { "binding" "controller_action SCREENSHOT" } } } }
+ "button_back_left" { "activators" { "Full_Press" { "bindings" { "binding" "game_action InGameAction1" } } } }
+ "button_back_right" { "activators" { "Full_Press" { "bindings" { "binding" "mode_shift dpad 99" } } } }
+ "button_back_left_upper" { "activators" { "Full_Press" {
+ "bindings" { "binding" "xinput_button A" }
+ "settings" { "toggle" "1" "interruptable" "0" }
+ } } }
+ "button_back_right_upper" { "activators" { "Full_Press" { "bindings" { "binding" "key_press F" } } } }
+ }
+ }
+
+ // --- dpad: xinput dpad / key / mouse_wheel / key-combo, one per direction ---
+ "group"
+ {
+ "id" "2"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button DPAD_UP" } } } }
+ "dpad_south" { "activators" { "Full_Press" { "bindings" { "binding" "key_press DOWN_ARROW" } } } }
+ "dpad_east" { "activators" { "Full_Press" { "bindings" { "binding" "mouse_wheel SCROLL_UP" } } } }
+ "dpad_west" { "activators" { "Full_Press" { "bindings" {
+ "binding" "key_press LEFT_SHIFT"
+ "binding" "key_press TAB"
+ } } } }
+ }
+ }
+
+ // --- left stick: joystick_move + click -> L3 ; deadzone setting ---
+ "group"
+ {
+ "id" "3"
+ "mode" "joystick_move"
+ "inputs" { "click" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button JOYSTICK_LEFT" } } } } }
+ "settings" { "deadzone" "5000" "invert_y" "0" }
+ }
+
+ // --- right stick: REFERENCE -> group 10 (tests reference resolution to a usable mode) ---
+ "group"
+ {
+ "id" "4"
+ "mode" "reference"
+ "settings" { "referenced_mode" "10" }
+ }
+
+ // --- left pad: scrollwheel ---
+ "group" { "id" "5" "mode" "scrollwheel" }
+
+ // --- right pad: absolute_mouse + Soft_Press click -> LMB ---
+ "group"
+ {
+ "id" "6"
+ "mode" "absolute_mouse"
+ "inputs" { "click" { "activators" { "Soft_Press" { "bindings" { "binding" "mouse_button LEFT" } } } } }
+ "settings" { "sensitivity" "200" "invert_y" "0" }
+ }
+
+ // --- left trigger: staged full-pull (edge) -> RMB ; haptic setting ---
+ "group"
+ {
+ "id" "7"
+ "mode" "trigger"
+ "inputs" { "edge" { "activators" { "Full_Press" {
+ "bindings" { "binding" "mouse_button RIGHT" }
+ "settings" { "haptic_intensity" "2" }
+ } } } }
+ }
+
+ // --- right trigger: analog xinput passthrough -> Axis ---
+ "group"
+ {
+ "id" "8"
+ "mode" "trigger"
+ "inputs" { "click" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button TRIGGER_RIGHT" } } } } }
+ }
+
+ // --- reference target for group 4: joystick_move + click -> R3 ---
+ "group"
+ {
+ "id" "10"
+ "mode" "joystick_move"
+ "inputs" { "click" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button JOYSTICK_RIGHT" } } } } }
+ }
+
+ // --- mode-shift alternate for right_trackpad: must NOT clobber the base absolute_mouse (group 6) ---
+ "group"
+ {
+ "id" "17"
+ "mode" "dpad"
+ "inputs" { "dpad_north" { "activators" { "Full_Press" { "bindings" { "binding" "key_press 1" } } } } }
+ }
+
+ "preset"
+ {
+ "id" "0"
+ "name" "Default"
+ "group_source_bindings"
+ {
+ "0" "button_diamond active"
+ "1" "switch active"
+ "2" "dpad active"
+ "3" "joystick active"
+ "4" "right_joystick active"
+ "5" "left_trackpad active"
+ "6" "right_trackpad active"
+ "17" "right_trackpad active modeshift"
+ "7" "left_trigger active"
+ "8" "right_trigger active"
+ "9" "gyro inactive"
+ }
+ }
+}
diff --git a/app/src/test/resources/sc/ksp_worstcase_xboxone.vdf b/app/src/test/resources/sc/ksp_worstcase_xboxone.vdf
new file mode 100644
index 0000000000..37ae6b0a48
--- /dev/null
+++ b/app/src/test/resources/sc/ksp_worstcase_xboxone.vdf
@@ -0,0 +1,10010 @@
+"controller_mappings"
+{
+ "version" "3"
+ "title" "Official Simplified Configuration for KSP"
+ "description" "Based on the Cursor paradigm present in the console Enhanced Edition. Designed favoring user friendliness over complete access to all functionality."
+ "creator" "76561198069003879"
+ "controller_type" "controller_xboxone"
+ "Timestamp" "1529008071"
+ "actions"
+ {
+ "MenuControls"
+ {
+ "title" "#Set_Menu"
+ "legacy_set" "0"
+ }
+ "FlightControls"
+ {
+ "title" "#Set_Flight"
+ "legacy_set" "0"
+ }
+ "DockingControls"
+ {
+ "title" "#Set_Docking"
+ "legacy_set" "0"
+ }
+ "EditorControls"
+ {
+ "title" "#Set_Editor"
+ "legacy_set" "0"
+ }
+ "MapControls"
+ {
+ "title" "#Set_Map"
+ "legacy_set" "0"
+ }
+ "EVAControls"
+ {
+ "title" "#Set_EVA"
+ "legacy_set" "0"
+ }
+ }
+ "action_layers"
+ {
+ "Preset_1000005"
+ {
+ "title" "FlightCursor"
+ "legacy_set" "1"
+ "set_layer" "1"
+ "parent_set_name" "FlightControls"
+ }
+ "Preset_1000006"
+ {
+ "title" "DockingCursor"
+ "legacy_set" "1"
+ "set_layer" "1"
+ "parent_set_name" "DockingControls"
+ }
+ "Preset_1000007"
+ {
+ "title" "MapCursor"
+ "legacy_set" "1"
+ "set_layer" "1"
+ "parent_set_name" "MapControls"
+ }
+ "Preset_1000009"
+ {
+ "title" "EVACursor"
+ "legacy_set" "1"
+ "set_layer" "1"
+ "parent_set_name" "EVAControls"
+ }
+ }
+ "localization"
+ {
+ "english"
+ {
+ "Set_Menu" "Menu Controls"
+ "Set_Flight" "Flight Controls"
+ "Set_Docking" "Docking Controls"
+ "Set_Editor" "Editor Controls"
+ "Set_Map" "Map Controls"
+ "Set_EVA" "EVA Controls"
+ "b_menuUp" "Navigate/View Up"
+ "b_menuDown" "Navigate/View Down"
+ "b_menuLeft" "Navigate/View Left"
+ "b_menuRight" "Navigate/View Right"
+ "b_timeWarpIncr" "TimeWarp Increase"
+ "b_timeWarpDecr" "TimeWarp Decrease"
+ "b_timeWarpStop" "TimeWarp Stop"
+ "b_bksp" "Reset View"
+ "b_cameraSlew" "Camera Slew"
+ "b_camModes" "Camera Mode"
+ "b_camViews" "Camera View"
+ "b_vesselPrev" "Prev Vessel"
+ "b_vesselNext" "Next Vessel"
+ "b_navBall" "Toggle NavBall"
+ "b_map" "Map View"
+ "b_esc" "Pause Menu"
+ "b_alt" "ModKey"
+ "b_shifter" "Mode Shift"
+ "b_rmb" "Mouselook/Menu"
+ "b_mouselook" "Mouselook Toggle"
+ "b_caps" "Precision Controls"
+ "b_stagingMode" "Staging Controls"
+ "b_dockingMode" "Docking Controls"
+ "b_fltQ" "Roll Left"
+ "b_fltE" "Roll Right"
+ "b_fltW" "Pitch/EVA Forward"
+ "b_fltS" "Pitch/EVA Back"
+ "b_fltA" "Yaw/EVA Left"
+ "b_fltD" "Yaw/EVA Right"
+ "b_fltX" "Throttle Cuttoff"
+ "b_fltZ" "Full Throttle"
+ "b_fltR" "RCS Toggle"
+ "b_fltC" "IVA Toggle"
+ "b_fltG" "Gear Action Group"
+ "b_fltDel" "Docking Controls"
+ "b_fltIns" "StagingControls"
+ "b_fltCtrl" "Decr. Throttle"
+ "b_fltShift" "Incr. Throttle"
+ "b_fltF" "SAS/EVA Grab"
+ "b_fltT" "SAS Toggle"
+ "b_fltBksp" "Abort Trigger"
+ "b_fltSpace" "Staging"
+ "b_fltB" "Brakes"
+ "b_dckCtrl" "Translate Down"
+ "b_dckShift" "Translate Up"
+ "b_dckW" "Translate Forward"
+ "b_dckS" "Translate Right"
+ "b_dckA" "Translate Left"
+ "b_dckD" "Translate Right"
+ "b_dckR" "RCS Toggle"
+ "b_dckSpace" "Rot/Lin Toggle"
+ "b_edLTriggerFull" "Camera Slew"
+ "b_edQ" "Roll Part Left"
+ "b_edE" "Roll Part Right"
+ "b_edW" "Pitch Part Down"
+ "b_edS" "Pitch Part Up"
+ "b_edA" "Yaw Part Left"
+ "b_edD" "Yaw Part Right"
+ "b_edCtrlZ" "Undo"
+ "b_edCtrlY" "Redo"
+ "b_edC" "Snap Toggle"
+ "b_edX" "Symmetry Cycle"
+ "b_edDel" "Delete Part"
+ "b_edSpace" "Reset Part Rotation"
+ "b_edAlt" "Duplicate Sel. Part"
+ "b_edShift" "Precision Mode"
+ "b_ed1" "Place Tool"
+ "b_ed2" "Offset Tool"
+ "b_ed3" "Rotate Tool"
+ "b_ed4" "Root Tool"
+ "b_edR" "Symmetry Mode Toggle"
+ "b_edF" "Coord Frame Toggle"
+ "b_mapTab" "Cycle Map Focus"
+ "b_mapLeave" "Leave Map"
+ "b_mapNavBall" "Enable NavBall"
+ "b_fltRMB" "Mouselook/Part Menus"
+ "b_dckRMB" "Mouselook/Part Menus"
+ }
+ }
+ "group"
+ {
+ "id" "0"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "1"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "doubletap"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSLASH, #b_mouselook"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "sensitivity" "72"
+ "doubletap_beep" "1"
+ "rotation" "8"
+ "friction" "1"
+ "friction_vert_scale" "119"
+ "sensitivity_vert_scale" "89"
+ "acceleration" "1"
+ "edge_spin_radius" "32767"
+ "doubetap_max_duration" "500"
+ }
+ }
+ "group"
+ {
+ "id" "2"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "3"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press FORWARD_SLASH, #b_timeWarpStop"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press PERIOD, #b_timeWarpIncr"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "4"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press W, #b_menuUp"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press S, #b_menuDown"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press D, #b_menuRight"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press A, #b_menuLeft"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ }
+ }
+ "group"
+ {
+ "id" "5"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "6"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "7"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "8"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "9"
+ "mode" "mouse_joystick"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "10"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press W"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press S"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press D"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press A"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "11"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "12"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press SPACE"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press M"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press L"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "13"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "14"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press M"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "15"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "16"
+ "mode" "trigger"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "17"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "18"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "19"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "20"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "21"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "22"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "23"
+ "mode" "mouse_joystick"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "24"
+ "mode" "trigger"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "25"
+ "mode" "touch_menu"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "touch_menu_button_count" "2"
+ }
+ }
+ "group"
+ {
+ "id" "26"
+ "mode" "touch_menu"
+ "inputs"
+ {
+ "touch_menu_button_0"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 1"
+ }
+ }
+ }
+ }
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2"
+ }
+ }
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 4"
+ }
+ }
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 5"
+ }
+ }
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 6"
+ }
+ }
+ }
+ }
+ "touch_menu_button_6"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 7"
+ }
+ }
+ }
+ }
+ "touch_menu_button_7"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 8"
+ }
+ }
+ }
+ }
+ "touch_menu_button_8"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 9"
+ }
+ }
+ }
+ }
+ "touch_menu_button_9"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 0"
+ }
+ }
+ }
+ }
+ "touch_menu_button_10"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSPACE"
+ }
+ }
+ }
+ }
+ "touch_menu_button_11"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press G"
+ }
+ }
+ }
+ }
+ "touch_menu_button_12"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press B"
+ }
+ }
+ }
+ }
+ "touch_menu_button_13"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press U"
+ }
+ }
+ }
+ }
+ "touch_menu_button_14"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press T"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "touch_menu_button_count" "16"
+ "touch_menu_opacity" "74"
+ "touch_menu_position_x" "27"
+ "touch_menu_position_y" "24"
+ "touch_menu_scale" "99"
+ }
+ }
+ "group"
+ {
+ "id" "27"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW, #b_menuUp"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW, #b_menuDown"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW, #b_menuRight"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW, #b_menuLeft"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action SHOW_KEYBOARD, #b_cameraSlew"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "deadzone" "1176"
+ "analog_emulation_period" "88"
+ "analog_emulation_duty_cycle_pct" "8"
+ }
+ }
+ "group"
+ {
+ "id" "28"
+ "mode" "touch_menu"
+ "inputs"
+ {
+ "touch_menu_button_0"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press B"
+ }
+ }
+ }
+ }
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press G"
+ }
+ }
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press U"
+ }
+ }
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSPACE"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "touch_menu_button_count" "4"
+ "touch_menu_opacity" "68"
+ "touch_menu_position_x" "12"
+ "touch_menu_position_y" "82"
+ "touch_menu_scale" "74"
+ }
+ }
+ "group"
+ {
+ "id" "29"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "scroll_angle" "143"
+ }
+ }
+ "group"
+ {
+ "id" "30"
+ "mode" "touch_menu"
+ "inputs"
+ {
+ "touch_menu_button_0"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R, RCS"
+ }
+ }
+ }
+ }
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press T, Toggle SAS"
+ }
+ }
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press G, Gears"
+ }
+ }
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press B, Brakes"
+ }
+ }
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press U, Lights"
+ }
+ }
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSPACE, Abort / Reset View"
+ }
+ }
+ }
+ }
+ "touch_menu_button_6"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press C, Camera Modes"
+ }
+ }
+ }
+ }
+ "touch_menu_button_7"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press V, Camera Views"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "touch_menu_button_count" "9"
+ "touch_menu_opacity" "68"
+ "touch_menu_position_x" "93"
+ "touch_menu_position_y" "21"
+ "touch_menu_scale" "79"
+ }
+ }
+ "group"
+ {
+ "id" "31"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "32"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT, b_timeWarpDecr"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press PERIOD, b_timeWarpIncr"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F5"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ "binding" "mouse_button RIGHT"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "33"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press H"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press N"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press CAPSLOCK"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ }
+ }
+ "group"
+ {
+ "id" "34"
+ "mode" "joystick_camera"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "curve_exponent" "2"
+ "output_joystick" "2"
+ }
+ }
+ "group"
+ {
+ "id" "35"
+ "mode" "touch_menu"
+ "inputs"
+ {
+ "touch_menu_button_0"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 1, Action Group 1 "
+ }
+ }
+ }
+ }
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2, Action Group 2"
+ }
+ }
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 3, Action Group 3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 4, Action Group 4"
+ }
+ }
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 5, Action Group 5"
+ }
+ }
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 6, Action Group 6"
+ }
+ }
+ }
+ }
+ "touch_menu_button_6"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 7, Action Group 7"
+ }
+ }
+ }
+ }
+ "touch_menu_button_7"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 8, Action Group 8"
+ }
+ }
+ }
+ }
+ "touch_menu_button_8"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 9, Action Group 9"
+ }
+ }
+ }
+ }
+ "touch_menu_button_9"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 0, Action Group 10"
+ }
+ }
+ }
+ }
+ "touch_menu_button_10"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F5, Quicksave"
+ }
+ }
+ }
+ }
+ "touch_menu_button_11"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F9, Quickload"
+ }
+ }
+ }
+ }
+ "touch_menu_button_12"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F1, Screenshot"
+ }
+ }
+ }
+ }
+ "touch_menu_button_13"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F2, Toggle UI"
+ }
+ }
+ }
+ }
+ "touch_menu_button_14"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F3, Flight Log"
+ }
+ }
+ }
+ }
+ "touch_menu_button_15"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F4, Toggle Vessel Markers"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "touch_menu_button_count" "16"
+ "touch_menu_opacity" "69"
+ "touch_menu_position_x" "11"
+ "touch_menu_position_y" "12"
+ "touch_menu_scale" "67"
+ }
+ }
+ "group"
+ {
+ "id" "36"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_BRACKET"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_BRACKET"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ }
+ }
+ "group"
+ {
+ "id" "37"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press V, #b_camViews"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press C, #b_camModes"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_BRACKET, #b_vesselNext"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_BRACKET, #b_vesselPrev"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_PERIOD, #b_navBall"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "deadzone" "1179"
+ "analog_emulation_period" "104"
+ "analog_emulation_duty_cycle_pct" "5"
+ }
+ }
+ "group"
+ {
+ "id" "38"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press E"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "10"
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Q"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ "repeat_rate" "10"
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "deadzone" "351"
+ "analog_emulation_period" "17"
+ "analog_emulation_duty_cycle_pct" "24"
+ "gyro_button" "6"
+ }
+ }
+ "group"
+ {
+ "id" "39"
+ "mode" "trigger"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Z"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "40"
+ "mode" "trigger"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press X"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "41"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press SPACE, #b_fltSpace"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press T, #b_fltT"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press B, #b_fltB"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press G, #b_fltG"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "42"
+ "mode" "trigger"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Q, #b_fltQ"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "edge_binding_radius" "0"
+ }
+ }
+ "group"
+ {
+ "id" "43"
+ "mode" "trigger"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press E, #b_fltE"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "edge_binding_radius" "0"
+ }
+ }
+ "group"
+ {
+ "id" "44"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press SPACE, #b_dckSpace"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R, #b_dckR"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F, #b_fltF"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press T, #b_fltT"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "45"
+ "mode" "trigger"
+ "inputs"
+ {
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Q, #b_fltQ"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "edge_binding_radius" "20649"
+ }
+ }
+ "group"
+ {
+ "id" "46"
+ "mode" "trigger"
+ "inputs"
+ {
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press E, #b_fltE"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "edge_binding_radius" "20649"
+ }
+ }
+ "group"
+ {
+ "id" "47"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DELETE, #b_edDel"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R, #b_edR"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "48"
+ "mode" "trigger"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Q, b_edQ"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "adaptive_threshold" "0"
+ }
+ }
+ "group"
+ {
+ "id" "49"
+ "mode" "trigger"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press E, b_edE"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "adaptive_threshold" "0"
+ }
+ }
+ "group"
+ {
+ "id" "50"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press T, #b_fltT"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press B, #b_fltB"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "51"
+ "mode" "trigger"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Q, #b_fltQ"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "52"
+ "mode" "trigger"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press E, #b_fltE"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "53"
+ "mode" "mouse_joystick"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "54"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "doubletap"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSLASH, #b_mouselook"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "sensitivity" "73"
+ "doubletap_beep" "1"
+ "rotation" "9"
+ "friction" "1"
+ "acceleration" "2"
+ "doubetap_max_duration" "500"
+ "mouse_dampening_trigger" "3"
+ "mouse_trigger_clamp_amount" "34"
+ }
+ }
+ "group"
+ {
+ "id" "55"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "scroll_angle" "147"
+ }
+ }
+ "group"
+ {
+ "id" "56"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 3, #b_ed3"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2, #b_ed2"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 4, #b_ed4"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 1, #b_ed1"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ "button_dist" "28190"
+ }
+ }
+ "group"
+ {
+ "id" "57"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press W, #b_edW"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press S, #b_edS"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press D, #b_edD"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press A, #b_edA"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action SHOW_KEYBOARD, #b_cameraSlew"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "58"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "59"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "doubletap"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSLASH, #b_mouselook"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "sensitivity" "73"
+ "doubletap_beep" "1"
+ "rotation" "6"
+ "friction" "1"
+ "acceleration" "1"
+ "doubetap_max_duration" "500"
+ }
+ }
+ "group"
+ {
+ "id" "60"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press W, #b_fltW"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press S, #b_fltS"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press D, #b_fltD"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press A, #b_fltA"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action add_layer 9 0 0"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "61"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "scroll_angle" "134"
+ }
+ }
+ "group"
+ {
+ "id" "62"
+ "mode" "touch_menu"
+ "inputs"
+ {
+ "touch_menu_button_0"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 1, Action Group 1"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2, Action Group 2"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 3, Action Group 3"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 4, Action Group 4"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 5, Action Group 5"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 6, Action Group 6"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_6"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 7, Action Group 7"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_7"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 8, Action Group 8"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_8"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 9, Action Group 9"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_9"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 0, Action Group 10"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_10"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F5, Quicksave"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_11"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F9, Quickload"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_12"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F10, Temp Display Mode"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_13"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F11, Thermal Overlay"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_14"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F3, Flight Log"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_15"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F2, Toggle UI"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ "touch_menu_button_count" "16"
+ "touch_menu_opacity" "69"
+ "touch_menu_position_x" "4"
+ "touch_menu_position_y" "19"
+ }
+ }
+ "group"
+ {
+ "id" "63"
+ "mode" "touch_menu"
+ "inputs"
+ {
+ "touch_menu_button_0"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R"
+ }
+ }
+ }
+ }
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F"
+ }
+ }
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Z"
+ }
+ }
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Y"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "touch_menu_button_count" "7"
+ }
+ }
+ "group"
+ {
+ "id" "64"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press W, #b_fltW"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press S, #b_fltS"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press D, #b_fltD"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press A, #b_fltA"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action add_layer 7 0 0"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "analog_emulation_period" "54"
+ "analog_emulation_duty_cycle_pct" "7"
+ }
+ }
+ "group"
+ {
+ "id" "65"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "66"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press CAPSLOCK, #b_caps"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R, #b_dckR"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press COMMA, #b_timeWarpDecr"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press PERIOD, #b_timeWarpIncr"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "67"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press V, #b_camViews"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press C, #b_camModes"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_BRACKET, #b_vesselNext"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_BRACKET, #b_vesselPrev"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_PERIOD, #b_navBall"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "68"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT, #b_rmb"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "doubletap"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSLASH, #b_mouselook"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "sensitivity" "72"
+ "doubletap_beep" "1"
+ "rotation" "7"
+ "friction" "1"
+ "acceleration" "1"
+ "doubetap_max_duration" "500"
+ }
+ }
+ "group"
+ {
+ "id" "69"
+ "mode" "touch_menu"
+ "inputs"
+ {
+ "touch_menu_button_0"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press L, Headlamp"
+ }
+ }
+ }
+ }
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press U, Lights"
+ }
+ }
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press G, Gears"
+ }
+ }
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R, RCS"
+ }
+ }
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSPACE, Abort"
+ }
+ }
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press INSERT, Staging Controls"
+ }
+ }
+ }
+ }
+ "touch_menu_button_6"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DELETE, Docking Controls"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "touch_menu_button_count" "7"
+ "touch_menu_opacity" "69"
+ "touch_menu_position_x" "100"
+ "touch_menu_position_y" "19"
+ }
+ }
+ "group"
+ {
+ "id" "70"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "71"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press SPACE, #b_edSpace"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press X, #b_edX"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press C, #b_edC"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "72"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F, #b_edF"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R, #b_edR"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ "button_dist" "29009"
+ }
+ }
+ "group"
+ {
+ "id" "73"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ "doubletap"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSLASH, #b_mouselook"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "sensitivity" "73"
+ "doubletap_beep" "1"
+ "rotation" "6"
+ "friction" "1"
+ "acceleration" "1"
+ "doubetap_max_duration" "500"
+ }
+ }
+ "group"
+ {
+ "id" "74"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press W, #b_dckW"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press S, #b_dckS"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press D, #b_dckD"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press A, #b_dckA"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action add_layer 8 0 0"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "analog_emulation_period" "54"
+ "analog_emulation_duty_cycle_pct" "7"
+ }
+ }
+ "group"
+ {
+ "id" "75"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "scroll_angle" "142"
+ }
+ }
+ "group"
+ {
+ "id" "76"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "77"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press TAB"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press CAPSLOCK, #b_caps"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press COMMA, #b_timeWarpDecr"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press PERIOD, #b_timeWarpIncr"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "78"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "79"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press V, #b_camViews"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press C, #b_camModes"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_BRACKET, #b_vesselNext"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_BRACKET, #b_vesselPrev"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "80"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "scroll_angle" "142"
+ }
+ }
+ "group"
+ {
+ "id" "81"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW, #b_menuUp"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW, #b_menuDown"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW, #b_menuRight"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW, #b_menuLeft"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_PERIOD, #b_navBall"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "82"
+ "mode" "touch_menu"
+ "inputs"
+ {
+ "touch_menu_button_0"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 1, Action Group 1"
+ }
+ }
+ }
+ }
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2, Action Group 2"
+ }
+ }
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 3, Action Group 3"
+ }
+ }
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 4, Action Group 4"
+ }
+ }
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 5, Action Group 5"
+ }
+ }
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 6, Action Group 6"
+ }
+ }
+ }
+ }
+ "touch_menu_button_6"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 7, Action Group 7"
+ }
+ }
+ }
+ }
+ "touch_menu_button_7"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 8, Action Group 8"
+ }
+ }
+ }
+ }
+ "touch_menu_button_8"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 9, Action Group 9"
+ }
+ }
+ }
+ }
+ "touch_menu_button_9"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 0, Action Group 10"
+ }
+ }
+ }
+ }
+ "touch_menu_button_10"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F5, Quicksave"
+ }
+ }
+ }
+ }
+ "touch_menu_button_11"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F9, Quickload"
+ }
+ }
+ }
+ }
+ "touch_menu_button_12"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F10, Temp Display Mode"
+ }
+ }
+ }
+ }
+ "touch_menu_button_13"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F11, Thermal Overlay"
+ }
+ }
+ }
+ }
+ "touch_menu_button_14"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F3, Flight Log"
+ }
+ }
+ }
+ }
+ "touch_menu_button_15"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F2, Toggle UI"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "touch_menu_button_count" "16"
+ "touch_menu_opacity" "83"
+ "touch_menu_position_x" "7"
+ "touch_menu_position_y" "14"
+ }
+ }
+ "group"
+ {
+ "id" "83"
+ "mode" "touch_menu"
+ "inputs"
+ {
+ "touch_menu_button_0"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press L, Headlamp"
+ }
+ }
+ }
+ }
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press U, Lights"
+ }
+ }
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press G, Gears"
+ }
+ }
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R, RCS"
+ }
+ }
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSPACE, Abort"
+ }
+ }
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press INSERT, Staging Controls"
+ }
+ }
+ }
+ }
+ "touch_menu_button_6"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DELETE, Docking Controls"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "touch_menu_button_count" "7"
+ "touch_menu_opacity" "83"
+ "touch_menu_position_x" "100"
+ "touch_menu_position_y" "12"
+ }
+ }
+ "group"
+ {
+ "id" "84"
+ "mode" "mouse_joystick"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "85"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ "output_joystick" "2"
+ }
+ }
+ "group"
+ {
+ "id" "86"
+ "mode" "joystick_camera"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSLASH, #b_mouselook"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "swipe_duration" "3"
+ "haptic_intensity" "3"
+ "output_joystick" "2"
+ "joystick_smoothing" "1"
+ }
+ }
+ "group"
+ {
+ "id" "87"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSLASH"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSLASH"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSLASH"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSLASH"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "gyro_button" "2"
+ "gyro_neutral" "16383"
+ }
+ }
+ "group"
+ {
+ "id" "88"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSLASH"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "edge_binding_radius" "17299"
+ "output_joystick" "1"
+ "gyro_button" "2"
+ }
+ }
+ "group"
+ {
+ "id" "89"
+ "mode" "joystick_camera"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "gyro_button" "2"
+ }
+ }
+ "group"
+ {
+ "id" "90"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "91"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW, #b_menuUp"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW, #b_menuDown"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW, #b_menuRight"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW, #b_menuLeft"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button MIDDLE, #b_cameraSlew"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "97"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, #b_fltShift"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL, #b_fltCtrl"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RETURN"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F12"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "98"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, #b_fltShift"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL, #b_fltCtrl"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 1"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "99"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, #b_fltShift"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL, #b_fltCtrl"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 1"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "100"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press W, #b_edW"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press S, #b_edS"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press D, #b_edD"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press A, #b_edA"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "101"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, #b_fltShift"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL, #b_fltCtrl"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layout" "0"
+ }
+ }
+ "group"
+ {
+ "id" "102"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action SHOW_KEYBOARD"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ "sensitivity" "173"
+ "sensitivity_vert_scale" "89"
+ }
+ }
+ "group"
+ {
+ "id" "103"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ "settings"
+ {
+ "hold_repeats" "1"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press PAGE_DOWN"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press PAGE_UP"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ "layout" "0"
+ }
+ }
+ "group"
+ {
+ "id" "104"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "curve_exponent" "2"
+ "output_joystick" "2"
+ }
+ }
+ "group"
+ {
+ "id" "105"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ }
+ }
+ "group"
+ {
+ "id" "106"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT, #b_rmb"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ "sensitivity" "148"
+ }
+ }
+ "group"
+ {
+ "id" "107"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ }
+ }
+ "group"
+ {
+ "id" "108"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT, #b_rmb"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ "sensitivity" "149"
+ }
+ }
+ "group"
+ {
+ "id" "109"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ }
+ }
+ "group"
+ {
+ "id" "110"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "111"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ "output_joystick" "2"
+ }
+ }
+ "group"
+ {
+ "id" "112"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACKSLASH, #b_mouselook"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ "output_joystick" "2"
+ }
+ }
+ "group"
+ {
+ "id" "113"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "114"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT, #b_rmb"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ "sensitivity" "174"
+ "mouse_dampening_trigger" "3"
+ "mouse_trigger_clamp_amount" "34"
+ }
+ }
+ "group"
+ {
+ "id" "115"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ }
+ }
+ "group"
+ {
+ "id" "116"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F, #b_edF"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R, #b_edR"
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ "button_dist" "29009"
+ }
+ }
+ "group"
+ {
+ "id" "117"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT, #b_rmb"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ "sensitivity" "149"
+ }
+ }
+ "group"
+ {
+ "id" "118"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "119"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button MIDDLE"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "120"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "121"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "122"
+ "mode" "mouse_joystick"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "123"
+ "mode" "mouse_joystick"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "124"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "125"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button MIDDLE"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ "scroll_friction" "1"
+ }
+ }
+ "group"
+ {
+ "id" "126"
+ "mode" "mouse_joystick"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "127"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button MIDDLE"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ "scroll_friction" "1"
+ }
+ }
+ "group"
+ {
+ "id" "128"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "output_axis" "1"
+ }
+ }
+ "group"
+ {
+ "id" "129"
+ "mode" "radial_menu"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "130"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layout" "2"
+ "haptic_intensity_override" "1"
+ "analog_emulation_period" "1"
+ }
+ }
+ "group"
+ {
+ "id" "131"
+ "mode" "radial_menu"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "132"
+ "mode" "mouse_region"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "output_joystick" "3"
+ }
+ }
+ "group"
+ {
+ "id" "133"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layout" "0"
+ }
+ }
+ "group"
+ {
+ "id" "134"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "135"
+ "mode" "mouse_joystick"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "136"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button MIDDLE"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "137"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "138"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Z, #b_fltZ"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press X, #b_fltX"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_BRACKET, #b_vesselNext"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_BRACKET, #b_vesselPrev"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "139"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "140"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Z, #b_fltZ"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press X, #b_fltX"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_BRACKET, #b_vesselNext"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_BRACKET, #b_vesselPrev"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "141"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button MIDDLE"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "142"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 1, #b_ed1"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 3, #b_ed3"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2, #b_ed2"
+ "binding" "key_press 2, #b_ed2"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 4, #b_ed4"
+ "binding" "key_press 4, #b_ed4"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "143"
+ "mode" "scrollwheel"
+ "inputs"
+ {
+ "scroll_clockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_UP"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "scroll_counterclockwise"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_wheel SCROLL_DOWN"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "144"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F, #b_edF"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ "sensitivity" "149"
+ }
+ }
+ "group"
+ {
+ "id" "145"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ "sensitivity" "157"
+ }
+ }
+ "group"
+ {
+ "id" "146"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Z, #b_fltZ"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press X, #b_fltX"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_BRACKET, #b_vesselNext"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_BRACKET, #b_vesselPrev"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "147"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ "sensitivity" "146"
+ }
+ }
+ "group"
+ {
+ "id" "148"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R, #b_fltR"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press COMMA, #b_timeWarpDecr"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press PERIOD, #b_timeWarpIncr"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "149"
+ "mode" "trigger"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "150"
+ "mode" "trigger"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "151"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW"
+ }
+ }
+ }
+ }
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "152"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press C, #b_fltC"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "153"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_PLUS"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_DASH"
+ "binding" "key_press KEYPAD_DASH"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press PAGE_DOWN"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press PAGE_UP"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layout" "0"
+ }
+ }
+ "group"
+ {
+ "id" "154"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "155"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_PLUS"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_DASH"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layout" "0"
+ }
+ }
+ "group"
+ {
+ "id" "172"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_PLUS"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_DASH"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_DASH"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_PLUS"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layout" "0"
+ }
+ }
+ "group"
+ {
+ "id" "174"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "175"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "176"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F12"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "177"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "178"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "179"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "180"
+ "mode" "touch_menu"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "181"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "182"
+ "mode" "touch_menu"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "183"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "184"
+ "mode" "trigger"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "185"
+ "mode" "trigger"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "186"
+ "mode" "trigger"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "187"
+ "mode" "trigger"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "188"
+ "mode" "mouse_joystick"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "189"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action remove_layer 7 0 0"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ "output_joystick" "2"
+ "sensitivity" "157"
+ }
+ }
+ "group"
+ {
+ "id" "191"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "192"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "193"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F12"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "194"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "195"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ "output_joystick" "2"
+ }
+ }
+ "group"
+ {
+ "id" "196"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "197"
+ "mode" "touch_menu"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "198"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "199"
+ "mode" "touch_menu"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "200"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "201"
+ "mode" "trigger"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "202"
+ "mode" "trigger"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "203"
+ "mode" "mouse_joystick"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "204"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action remove_layer 8 0 0"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ "output_joystick" "2"
+ "sensitivity" "155"
+ }
+ }
+ "group"
+ {
+ "id" "205"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Z, #b_fltZ"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press X, #b_fltX"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_BRACKET, #b_vesselNext"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_BRACKET, #b_vesselPrev"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layout" "0"
+ }
+ }
+ "group"
+ {
+ "id" "207"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "208"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "209"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F12"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "210"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "211"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "212"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "213"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "214"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "215"
+ "mode" "trigger"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "216"
+ "mode" "trigger"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "217"
+ "mode" "mouse_joystick"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "218"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action remove_layer 9 0 0"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ "output_joystick" "2"
+ "sensitivity" "149"
+ }
+ }
+ "group"
+ {
+ "id" "220"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press SPACE, #b_fltSpace"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press R, #b_fltR"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press B, #b_fltB"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F, #b_fltF"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "221"
+ "mode" "trigger"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Q, #b_fltQ"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "222"
+ "mode" "trigger"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press E, #b_fltE"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "223"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press W, #b_fltW"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press S, #b_fltS"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press D, #b_fltD"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press A, #b_fltA"
+ }
+ }
+ }
+ }
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action add_layer 10 0 0"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "224"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press UP_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DOWN_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_ARROW"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ARROW"
+ }
+ }
+ }
+ }
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press P, #b_fltP"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layout" "0"
+ }
+ }
+ "group"
+ {
+ "id" "225"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_PLUS"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_DASH"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press PAGE_DOWN"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press PAGE_UP"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layout" "0"
+ }
+ }
+ "group"
+ {
+ "id" "226"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, #b_fltShift"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL, #b_fltCtrl"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 1"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layout" "0"
+ }
+ }
+ "group"
+ {
+ "id" "227"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "229"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button LEFT"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mouse_button RIGHT"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "230"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F12"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "231"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "232"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "233"
+ "mode" "dpad"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "234"
+ "mode" "trigger"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "235"
+ "mode" "trigger"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "236"
+ "mode" "absolute_mouse"
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "237"
+ "mode" "joystick_mouse"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action remove_layer 10 0 0"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ "output_joystick" "2"
+ }
+ }
+ "group"
+ {
+ "id" "238"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_A"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press U, #b_fltU"
+ }
+ }
+ }
+ }
+ "button_B"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press T, #b_fltT"
+ }
+ }
+ }
+ }
+ "button_X"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press COMMA, #b_timeWarpDecr"
+ }
+ }
+ }
+ }
+ "button_Y"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press PERIOD, #b_timeWarpIncr"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "239"
+ "mode" "trigger"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press TAB, #b_mapTab"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "240"
+ "mode" "trigger"
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press BACK_TICK"
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "241"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press Z, #b_fltZ"
+ }
+ }
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press X, #b_fltX"
+ }
+ }
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press RIGHT_BRACKET, #b_vesselNext"
+ }
+ }
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_BRACKET, #b_vesselPrev"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layout" "0"
+ }
+ }
+ "group"
+ {
+ "id" "92"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_escape"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press ESCAPE, #b_esc"
+ }
+ }
+ }
+ }
+ "button_menu"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action SHOW_KEYBOARD"
+ }
+ }
+ }
+ }
+ "left_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift button_diamond 32"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift dpad 146"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift right_joystick 103"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ }
+ }
+ "right_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, #b_alt"
+ }
+ }
+ }
+ }
+ "button_back_right"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, #b_alt"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "93"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_escape"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press ESCAPE, #b_esc"
+ }
+ }
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F3"
+ }
+ }
+ }
+ }
+ "button_menu"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_PERIOD, #b_mapNavBall"
+ }
+ }
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press DELETE, #b_fltDel"
+ }
+ }
+ }
+ }
+ "left_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift dpad 138"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift button_diamond 66"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift right_joystick 172"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ }
+ }
+ "right_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press M, #b_map"
+ }
+ }
+ "Double_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, #b_alt"
+ }
+ "settings"
+ {
+ "double_tap_time" "327"
+ }
+ }
+ }
+ }
+ "button_back_left"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift left_trackpad 62"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift right_trackpad 69"
+ }
+ }
+ }
+ }
+ "button_back_right"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, #b_alt"
+ }
+ }
+ }
+ }
+ "left_stick_click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift joystick 67"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift left_trigger 149"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift right_trigger 150"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "94"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_escape"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press ESCAPE, #b_esc"
+ }
+ }
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F3"
+ }
+ }
+ }
+ }
+ "button_menu"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_PERIOD, #b_mapNavBall"
+ }
+ }
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press INSERT, #b_fltIns"
+ }
+ }
+ }
+ }
+ "left_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift button_diamond 77"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ }
+ }
+ "right_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press M, #b_map"
+ }
+ }
+ "Double_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, #b_alt"
+ }
+ "settings"
+ {
+ "double_tap_time" "326"
+ }
+ }
+ }
+ }
+ "button_back_left"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift left_trackpad 82"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift right_trackpad 83"
+ }
+ }
+ }
+ }
+ "button_back_right"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, #b_alt"
+ }
+ }
+ }
+ }
+ "left_stick_click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift right_joystick 109"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift joystick 79"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "95"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_escape"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL, #b_edCtrlZ"
+ "binding" "key_press Z, #b_edCtrlZ"
+ }
+ }
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_CONTROL, #b_edCtrlY"
+ "binding" "key_press Y, #b_edCtrlY"
+ }
+ }
+ }
+ }
+ "button_menu"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action SHOW_KEYBOARD"
+ }
+ }
+ }
+ }
+ "left_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift dpad 142"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift button_diamond 71"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift right_joystick 153"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ }
+ }
+ "right_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, #b_edShift"
+ }
+ }
+ "Double_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, #b_alt"
+ }
+ "settings"
+ {
+ "double_tap_time" "326"
+ }
+ }
+ }
+ }
+ "button_back_left"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, #b_edShift"
+ }
+ }
+ }
+ }
+ "button_back_right"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, #b_edAlt"
+ }
+ }
+ }
+ }
+ "left_click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift left_trackpad 56"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift joystick 91"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ }
+ }
+ "right_click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift right_trackpad 72"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "96"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_escape"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press ESCAPE, #b_esc"
+ }
+ }
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F3"
+ }
+ }
+ }
+ }
+ "button_menu"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_PERIOD, #b_mapNavBall"
+ }
+ }
+ }
+ }
+ "left_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift button_diamond 148"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift right_joystick 155"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift dpad 205"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift right_trigger 239"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift left_trigger 240"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ }
+ }
+ "right_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press M, #b_map"
+ }
+ }
+ "Double_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, #b_alt"
+ }
+ "settings"
+ {
+ "double_tap_time" "330"
+ }
+ }
+ }
+ }
+ "button_back_right"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, #b_alt"
+ }
+ }
+ }
+ }
+ "left_stick_click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift joystick 81"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "173"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_menu"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action SHOW_KEYBOARD"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "190"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_menu"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action SHOW_KEYBOARD"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "206"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_menu"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action SHOW_KEYBOARD"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "group"
+ {
+ "id" "219"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_escape"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press ESCAPE, #b_esc"
+ }
+ }
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press F3"
+ }
+ }
+ }
+ }
+ "button_menu"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press KEYPAD_PERIOD, #b_navBall"
+ }
+ }
+ }
+ }
+ "left_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift right_joystick 225"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift button_diamond 238"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "mode_shift dpad 241"
+ }
+ "settings"
+ {
+ "interruptable" "0"
+ }
+ }
+ }
+ }
+ "right_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press M, #b_map"
+ }
+ }
+ "Double_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_ALT, #b_alt"
+ }
+ "settings"
+ {
+ "double_tap_time" "327"
+ }
+ }
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "228"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_menu"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action SHOW_KEYBOARD"
+ }
+ }
+ }
+ }
+ }
+ "settings"
+ {
+ "layer" "1"
+ }
+ }
+ "preset"
+ {
+ "id" "0"
+ "name" "MenuControls"
+ "group_source_bindings"
+ {
+ "92" "switch active"
+ "2" "button_diamond inactive"
+ "3" "button_diamond active"
+ "25" "button_diamond inactive"
+ "31" "button_diamond inactive modeshift"
+ "32" "button_diamond active modeshift"
+ "97" "dpad active"
+ "146" "dpad inactive modeshift"
+ "102" "right_joystick inactive"
+ "103" "right_joystick active modeshift"
+ "104" "right_joystick inactive modeshift"
+ "105" "right_joystick inactive modeshift"
+ "119" "right_joystick inactive"
+ "151" "right_joystick active"
+ "4" "left_trackpad inactive"
+ "28" "left_trackpad inactive"
+ "29" "left_trackpad inactive"
+ "35" "left_trackpad active modeshift"
+ "1" "right_trackpad active"
+ "33" "right_trackpad inactive modeshift"
+ "30" "right_trackpad active modeshift"
+ "0" "joystick inactive"
+ "26" "joystick inactive"
+ "27" "joystick inactive"
+ "36" "joystick inactive modeshift"
+ "37" "joystick active modeshift"
+ "136" "joystick inactive"
+ "145" "joystick active"
+ "24" "left_trigger active"
+ "40" "left_trigger active modeshift"
+ "16" "right_trigger active"
+ "39" "right_trigger active modeshift"
+ "38" "gyro inactive"
+ "118" "center_trackpad active"
+ }
+ }
+ "preset"
+ {
+ "id" "1"
+ "name" "FlightControls"
+ "group_source_bindings"
+ {
+ "93" "switch active"
+ "41" "button_diamond active"
+ "65" "button_diamond inactive modeshift"
+ "66" "button_diamond active modeshift"
+ "90" "button_diamond inactive"
+ "98" "dpad active"
+ "138" "dpad active modeshift"
+ "106" "right_joystick inactive"
+ "107" "right_joystick inactive modeshift"
+ "121" "right_joystick inactive"
+ "152" "right_joystick active"
+ "172" "right_joystick active modeshift"
+ "61" "left_trackpad inactive"
+ "62" "left_trackpad active modeshift"
+ "68" "right_trackpad active"
+ "69" "right_trackpad active modeshift"
+ "64" "joystick active"
+ "67" "joystick inactive modeshift"
+ "137" "joystick inactive"
+ "42" "left_trigger active"
+ "149" "left_trigger active modeshift"
+ "43" "right_trigger active"
+ "150" "right_trigger active modeshift"
+ "120" "center_trackpad inactive"
+ "122" "center_trackpad active"
+ }
+ }
+ "preset"
+ {
+ "id" "2"
+ "name" "DockingControls"
+ "group_source_bindings"
+ {
+ "94" "switch active"
+ "44" "button_diamond active"
+ "76" "button_diamond inactive modeshift"
+ "77" "button_diamond active modeshift"
+ "99" "dpad active"
+ "140" "dpad active modeshift"
+ "108" "right_joystick inactive"
+ "109" "right_joystick active modeshift"
+ "110" "right_joystick inactive"
+ "111" "right_joystick inactive"
+ "112" "right_joystick inactive"
+ "124" "right_joystick inactive"
+ "154" "right_joystick active"
+ "75" "left_trackpad inactive"
+ "82" "left_trackpad active modeshift"
+ "73" "right_trackpad active"
+ "83" "right_trackpad active modeshift"
+ "84" "right_trackpad inactive"
+ "85" "right_trackpad inactive"
+ "74" "joystick active"
+ "78" "joystick inactive modeshift"
+ "79" "joystick inactive modeshift"
+ "139" "joystick inactive"
+ "45" "left_trigger active"
+ "46" "right_trigger active"
+ "87" "gyro inactive"
+ "88" "gyro inactive"
+ "89" "gyro inactive"
+ "123" "center_trackpad active"
+ }
+ }
+ "preset"
+ {
+ "id" "3"
+ "name" "EditorControls"
+ "group_source_bindings"
+ {
+ "95" "switch active"
+ "47" "button_diamond active"
+ "70" "button_diamond inactive modeshift"
+ "71" "button_diamond active modeshift"
+ "100" "dpad active"
+ "142" "dpad active modeshift"
+ "113" "right_joystick inactive"
+ "114" "right_joystick inactive"
+ "115" "right_joystick inactive modeshift"
+ "116" "right_joystick inactive modeshift"
+ "125" "right_joystick inactive"
+ "131" "right_joystick inactive"
+ "132" "right_joystick inactive"
+ "133" "right_joystick active"
+ "153" "right_joystick active modeshift"
+ "55" "left_trackpad inactive"
+ "56" "left_trackpad active modeshift"
+ "53" "right_trackpad inactive"
+ "54" "right_trackpad active"
+ "63" "right_trackpad inactive modeshift"
+ "72" "right_trackpad active modeshift"
+ "57" "joystick inactive"
+ "58" "joystick inactive"
+ "91" "joystick inactive modeshift"
+ "141" "joystick inactive"
+ "144" "joystick active"
+ "48" "left_trigger active"
+ "49" "right_trigger active"
+ "135" "center_trackpad active"
+ }
+ }
+ "preset"
+ {
+ "id" "4"
+ "name" "MapControls"
+ "group_source_bindings"
+ {
+ "96" "switch active"
+ "50" "button_diamond active"
+ "148" "button_diamond active modeshift"
+ "101" "dpad active"
+ "205" "dpad active modeshift"
+ "117" "right_joystick inactive"
+ "127" "right_joystick inactive"
+ "128" "right_joystick inactive"
+ "129" "right_joystick inactive"
+ "130" "right_joystick active"
+ "134" "right_joystick inactive"
+ "155" "right_joystick active modeshift"
+ "80" "left_trackpad inactive"
+ "59" "right_trackpad active"
+ "60" "joystick active"
+ "81" "joystick inactive modeshift"
+ "143" "joystick inactive"
+ "147" "joystick inactive"
+ "51" "left_trigger active"
+ "240" "left_trigger active modeshift"
+ "52" "right_trigger active"
+ "239" "right_trigger active modeshift"
+ "126" "center_trackpad active"
+ }
+ }
+ "preset"
+ {
+ "id" "5"
+ "name" "EVAControls"
+ "group_source_bindings"
+ {
+ "219" "switch active"
+ "220" "button_diamond active"
+ "238" "button_diamond active modeshift"
+ "226" "dpad active"
+ "241" "dpad active modeshift"
+ "224" "right_joystick active"
+ "225" "right_joystick active modeshift"
+ "223" "joystick active"
+ "221" "left_trigger active"
+ "222" "right_trigger active"
+ "227" "center_trackpad active"
+ }
+ }
+ "preset"
+ {
+ "id" "6"
+ "name" "Preset_1000005"
+ "group_source_bindings"
+ {
+ "173" "switch active"
+ "174" "button_diamond active"
+ "175" "button_diamond active modeshift"
+ "176" "dpad active"
+ "177" "dpad active modeshift"
+ "178" "right_joystick active"
+ "179" "right_joystick active modeshift"
+ "180" "left_trackpad active modeshift"
+ "181" "right_trackpad active"
+ "182" "right_trackpad active modeshift"
+ "183" "joystick inactive"
+ "189" "joystick active"
+ "184" "left_trigger active"
+ "185" "left_trigger active modeshift"
+ "186" "right_trigger active"
+ "187" "right_trigger active modeshift"
+ "188" "center_trackpad active"
+ }
+ }
+ "preset"
+ {
+ "id" "7"
+ "name" "Preset_1000006"
+ "group_source_bindings"
+ {
+ "190" "switch active"
+ "191" "button_diamond active"
+ "192" "button_diamond active modeshift"
+ "193" "dpad active"
+ "194" "dpad active modeshift"
+ "195" "right_joystick active modeshift"
+ "196" "right_joystick active"
+ "197" "left_trackpad active modeshift"
+ "198" "right_trackpad active"
+ "199" "right_trackpad active modeshift"
+ "200" "joystick inactive"
+ "204" "joystick active"
+ "201" "left_trigger active"
+ "202" "right_trigger active"
+ "203" "center_trackpad active"
+ }
+ }
+ "preset"
+ {
+ "id" "8"
+ "name" "Preset_1000007"
+ "group_source_bindings"
+ {
+ "206" "switch active"
+ "207" "button_diamond active"
+ "208" "button_diamond active modeshift"
+ "209" "dpad active"
+ "210" "dpad active modeshift"
+ "211" "right_joystick active"
+ "212" "right_joystick active modeshift"
+ "213" "right_trackpad active"
+ "214" "joystick inactive"
+ "218" "joystick active"
+ "215" "left_trigger active"
+ "216" "right_trigger active"
+ "217" "center_trackpad active"
+ }
+ }
+ "preset"
+ {
+ "id" "9"
+ "name" "Preset_1000009"
+ "group_source_bindings"
+ {
+ "228" "switch active"
+ "229" "button_diamond active"
+ "230" "dpad active"
+ "231" "right_joystick active"
+ "232" "right_joystick active modeshift"
+ "233" "joystick inactive"
+ "237" "joystick active"
+ "234" "left_trigger active"
+ "235" "right_trigger active"
+ "236" "center_trackpad active"
+ }
+ }
+ "settings"
+ {
+ "action_set_trigger_cursor_show" "0"
+ "action_set_trigger_cursor_hide" "0"
+ }
+}
diff --git a/app/src/test/resources/sc/modeshift_v3.vdf b/app/src/test/resources/sc/modeshift_v3.vdf
new file mode 100644
index 0000000000..473e7bde5e
--- /dev/null
+++ b/app/src/test/resources/sc/modeshift_v3.vdf
@@ -0,0 +1,27 @@
+// Synthetic mode-shift config for step-3 mode-shift tests.
+// Base "Default" (id 0): A -> Q. Holding right_bumper does `mode_shift button_diamond 1` -> while held, the
+// button_diamond source uses group 1 (A -> M). Release restores the base. Group 1 is also listed as
+// "button_diamond active modeshift" in the preset (the importer decodes it on demand from the mode_shift binding).
+"controller_mappings"
+{
+ "version" "3"
+ "title" "Mode Shift (synthetic)"
+ "controller_type" "controller_neptune"
+
+ "actions" { "Default" { "title" "Main" "legacy_set" "1" } }
+
+ "group" { "id" "0" "mode" "four_buttons"
+ "inputs" { "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "key_press Q" } } } } } }
+ "group" { "id" "1" "mode" "four_buttons"
+ "inputs" { "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "key_press M" } } } } } }
+ "group" { "id" "2" "mode" "switches"
+ "inputs" { "right_bumper" { "activators" { "Full_Press" {
+ "bindings" { "binding" "mode_shift button_diamond 1" } } } } } }
+
+ "preset" { "id" "0" "name" "Default"
+ "group_source_bindings" {
+ "0" "button_diamond active"
+ "1" "button_diamond active modeshift"
+ "2" "switch active"
+ } }
+}
diff --git a/app/src/test/resources/sc/sc_bindsettings_test.vdf b/app/src/test/resources/sc/sc_bindsettings_test.vdf
new file mode 100644
index 0000000000..39460caa93
--- /dev/null
+++ b/app/src/test/resources/sc/sc_bindsettings_test.vdf
@@ -0,0 +1,61 @@
+"controller_mappings"
+{
+ "version" "3"
+ "title" "SC Bind-Settings Test (delays + toggle + macro)"
+ "description" "A=macro(1 then 2), B=toggle M (hold-latch), X=delayed N (start 500ms), Y=delayed-release O (end 500ms). Type into a text field to see them. Right pad = mouse."
+ "controller_type" "controller_triton"
+ "group"
+ {
+ "id" "0"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_a"
+ {
+ "activators"
+ {
+ "Full_Press" { "bindings" { "binding" "key_press 1" } }
+ "Full_Press" { "bindings" { "binding" "key_press 2" } }
+ }
+ }
+ "button_b" { "activators" { "Full_Press" { "bindings" { "binding" "key_press M" } "settings" { "toggle" "1" } } } }
+ "button_x" { "activators" { "Full_Press" { "bindings" { "binding" "key_press N" } "settings" { "delay_start" "500" } } } }
+ "button_y" { "activators" { "Full_Press" { "bindings" { "binding" "key_press O" } "settings" { "delay_end" "500" } } } }
+ }
+ }
+ "group"
+ {
+ "id" "1"
+ "mode" "joystick_move"
+ "inputs" { "click" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button JOYSTICK_LEFT" } } } } }
+ }
+ "group"
+ {
+ "id" "2"
+ "mode" "mouse"
+ "inputs" { "click" { "activators" { "Full_Press" { "bindings" { "binding" "mouse_button LEFT" } } } } }
+ "settings" { "sensitivity" "120" }
+ }
+ "group"
+ {
+ "id" "3"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_menu" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button START" } } } }
+ "button_escape" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SELECT" } } } }
+ }
+ }
+ "preset"
+ {
+ "id" "0"
+ "name" "Default"
+ "group_source_bindings"
+ {
+ "0" "button_diamond active"
+ "1" "joystick active"
+ "2" "right_trackpad active"
+ "3" "switch active"
+ }
+ }
+}
diff --git a/app/src/test/resources/sc/sc_gyro_mousejoy_test.vdf b/app/src/test/resources/sc/sc_gyro_mousejoy_test.vdf
new file mode 100644
index 0000000000..1a5a58b5b1
--- /dev/null
+++ b/app/src/test/resources/sc/sc_gyro_mousejoy_test.vdf
@@ -0,0 +1,78 @@
+"controller_mappings"
+{
+ "version" "3"
+ "title" "SC Gyro + MouseJoystick Test"
+ "description" "Gyro = mouse aim (grip-gated) to check A5 direction; right pad = mouse_joystick (displacement-from-center = cursor velocity); left stick = move. Best in a mouse-look game (DOOM)."
+ "controller_type" "controller_triton"
+ "group"
+ {
+ "id" "0"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button A" } } } }
+ "button_b" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button B" } } } }
+ "button_x" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button X" } } } }
+ "button_y" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button Y" } } } }
+ }
+ }
+ "group"
+ {
+ "id" "1"
+ "mode" "joystick_move"
+ "inputs" { "click" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button JOYSTICK_LEFT" } } } } }
+ }
+ "group"
+ {
+ "id" "2"
+ "mode" "mouse_joystick"
+ "inputs" { "click" { "activators" { "Full_Press" { "bindings" { "binding" "mouse_button LEFT" } } } } }
+ "settings" { "sensitivity" "100" }
+ }
+ "group"
+ {
+ "id" "3"
+ "mode" "trigger"
+ "inputs" {}
+ "settings" { "output_trigger" "1" }
+ }
+ "group"
+ {
+ "id" "4"
+ "mode" "trigger"
+ "inputs" {}
+ "settings" { "output_trigger" "2" }
+ }
+ "group"
+ {
+ "id" "5"
+ "mode" "gyro_to_mouse"
+ "inputs" {}
+ "settings" { "sensitivity" "100" }
+ }
+ "group"
+ {
+ "id" "6"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_menu" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button START" } } } }
+ "button_escape" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SELECT" } } } }
+ }
+ }
+ "preset"
+ {
+ "id" "0"
+ "name" "Default"
+ "group_source_bindings"
+ {
+ "0" "button_diamond active"
+ "1" "joystick active"
+ "2" "right_trackpad active"
+ "3" "left_trigger active"
+ "4" "right_trigger active"
+ "5" "gyro active"
+ "6" "switch active"
+ }
+ }
+}
diff --git a/app/src/test/resources/sc/sc_gyro_touch_test.vdf b/app/src/test/resources/sc/sc_gyro_touch_test.vdf
new file mode 100644
index 0000000000..d15579d5eb
--- /dev/null
+++ b/app/src/test/resources/sc/sc_gyro_touch_test.vdf
@@ -0,0 +1,83 @@
+"controller_mappings"
+{
+ "version" "3"
+ "title" "SC Gyro Touch-to-Aim Test"
+ "description" "Gyro = mouse aim, gated by ANY thumb-surface touch (gyro_ratchet_button_mask = 4 touch surfaces). Touch any pad/stick -> gyro aims; let go -> stops. Right pad = mouse, left stick = move."
+ "controller_type" "controller_triton"
+ "group"
+ {
+ "id" "0"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button A" } } } }
+ "button_b" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button B" } } } }
+ "button_x" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button X" } } } }
+ "button_y" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button Y" } } } }
+ }
+ }
+ "group"
+ {
+ "id" "1"
+ "mode" "joystick_move"
+ "inputs" { "click" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button JOYSTICK_LEFT" } } } } }
+ }
+ "group"
+ {
+ "id" "2"
+ "mode" "mouse"
+ "inputs" { "click" { "activators" { "Full_Press" { "bindings" { "binding" "mouse_button LEFT" } } } } }
+ "settings" { "sensitivity" "120" }
+ }
+ "group"
+ {
+ "id" "3"
+ "mode" "trigger"
+ "inputs" {}
+ "settings" { "output_trigger" "1" }
+ }
+ "group"
+ {
+ "id" "4"
+ "mode" "trigger"
+ "inputs" {}
+ "settings" { "output_trigger" "2" }
+ }
+ "group"
+ {
+ "id" "5"
+ "mode" "gyro_to_mouse"
+ "inputs" {}
+ "settings"
+ {
+ "sensitivity" "100"
+ "gyro_ratchet_button_mask" "211106234105856"
+ "gyro_ratchet_button_requireany_or_all" "1"
+ }
+ }
+ "group"
+ {
+ "id" "6"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_menu" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button START" } } } }
+ "button_escape" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SELECT" } } } }
+ }
+ }
+ "preset"
+ {
+ "id" "0"
+ "name" "Default"
+ "group_source_bindings"
+ {
+ "0" "button_diamond active"
+ "1" "joystick active"
+ "2" "right_trackpad active"
+ "3" "left_trigger active"
+ "4" "right_trigger active"
+ "5" "gyro active"
+ "6" "switch active"
+ }
+ }
+}
diff --git a/app/src/test/resources/sc/sc_newmodes_test.vdf b/app/src/test/resources/sc/sc_newmodes_test.vdf
new file mode 100644
index 0000000000..3269d0fb38
--- /dev/null
+++ b/app/src/test/resources/sc/sc_newmodes_test.vdf
@@ -0,0 +1,89 @@
+"controller_mappings"
+{
+ "version" "3"
+ "title" "SC New Modes Test (pad-joystick + stick-dpad)"
+ "description" "Left stick = dpad->WASD (stick-as-dpad); left trackpad = joystick_move (pad-as-joystick, RIGHT/camera stick); right trackpad = relative mouse. For DOOM-style validation."
+ "controller_type" "controller_triton"
+ "group"
+ {
+ "id" "0"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button A" } } } }
+ "button_b" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button B" } } } }
+ "button_x" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button X" } } } }
+ "button_y" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button Y" } } } }
+ }
+ }
+ "group"
+ {
+ "id" "1"
+ "mode" "dpad"
+ "inputs"
+ {
+ "dpad_north" { "activators" { "Full_Press" { "bindings" { "binding" "key_press W" } } } }
+ "dpad_south" { "activators" { "Full_Press" { "bindings" { "binding" "key_press S" } } } }
+ "dpad_west" { "activators" { "Full_Press" { "bindings" { "binding" "key_press A" } } } }
+ "dpad_east" { "activators" { "Full_Press" { "bindings" { "binding" "key_press D" } } } }
+ }
+ }
+ "group"
+ {
+ "id" "2"
+ "mode" "joystick_move"
+ "inputs" {}
+ "settings" { "output_joystick" "2" }
+ }
+ "group"
+ {
+ "id" "3"
+ "mode" "trigger"
+ "inputs" {}
+ "settings" { "output_trigger" "1" }
+ }
+ "group"
+ {
+ "id" "4"
+ "mode" "trigger"
+ "inputs" {}
+ "settings" { "output_trigger" "2" }
+ }
+ "group"
+ {
+ "id" "5"
+ "mode" "mouse"
+ "inputs"
+ {
+ "click" { "activators" { "Full_Press" { "bindings" { "binding" "mouse_button LEFT" } } } }
+ }
+ "settings" { "sensitivity" "120" }
+ }
+ "group"
+ {
+ "id" "6"
+ "mode" "switches"
+ "inputs"
+ {
+ "left_bumper" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SHOULDER_LEFT" } } } }
+ "right_bumper" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SHOULDER_RIGHT" } } } }
+ "button_menu" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button START" } } } }
+ "button_escape" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SELECT" } } } }
+ }
+ }
+ "preset"
+ {
+ "id" "0"
+ "name" "Default"
+ "group_source_bindings"
+ {
+ "0" "button_diamond active"
+ "1" "joystick active"
+ "2" "left_trackpad active"
+ "3" "left_trigger active"
+ "4" "right_trigger active"
+ "5" "right_trackpad active"
+ "6" "switch active"
+ }
+ }
+}
diff --git a/app/src/test/resources/sc/sc_region_single_test.vdf b/app/src/test/resources/sc/sc_region_single_test.vdf
new file mode 100644
index 0000000000..51dc00f70e
--- /dev/null
+++ b/app/src/test/resources/sc/sc_region_single_test.vdf
@@ -0,0 +1,107 @@
+"controller_mappings"
+{
+ "version" "3"
+ "title" "SC Region + SingleButton Test"
+ "description" "Right pad = mouse_region (left-quarter, 30% wide, inverted) for B1; left pad = single_button bound to key F for B2."
+ "controller_type" "controller_triton"
+ "group"
+ {
+ "id" "0"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button A" } } } }
+ "button_b" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button B" } } } }
+ "button_x" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button X" } } } }
+ "button_y" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button Y" } } } }
+ }
+ }
+ "group"
+ {
+ "id" "1"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ "click" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button JOYSTICK_LEFT" } } } }
+ }
+ "settings" { "deadzone_inner_radius" "3500" }
+ }
+ "group"
+ {
+ "id" "2"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ "click" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button JOYSTICK_RIGHT" } } } }
+ }
+ "settings" { "deadzone_inner_radius" "3500" }
+ }
+ "group"
+ {
+ "id" "3"
+ "mode" "trigger"
+ "inputs" {}
+ "settings" { "output_trigger" "1" }
+ }
+ "group"
+ {
+ "id" "4"
+ "mode" "trigger"
+ "inputs" {}
+ "settings" { "output_trigger" "2" }
+ }
+ "group"
+ {
+ "id" "5"
+ "mode" "single_button"
+ "inputs"
+ {
+ "click" { "activators" { "Full_Press" { "bindings" { "binding" "key_press F" } } } }
+ }
+ }
+ "group"
+ {
+ "id" "6"
+ "mode" "mouse_region"
+ "inputs"
+ {
+ "click" { "activators" { "Full_Press" { "bindings" { "binding" "mouse_button LEFT" } } } }
+ }
+ "settings"
+ {
+ "position_x" "25"
+ "position_y" "50"
+ "scale" "30"
+ "invert_x" "1"
+ "invert_y" "1"
+ }
+ }
+ "group"
+ {
+ "id" "7"
+ "mode" "switches"
+ "inputs"
+ {
+ "left_bumper" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SHOULDER_LEFT" } } } }
+ "right_bumper" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SHOULDER_RIGHT" } } } }
+ "button_menu" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button START" } } } }
+ "button_escape" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SELECT" } } } }
+ }
+ }
+ "preset"
+ {
+ "id" "0"
+ "name" "Default"
+ "group_source_bindings"
+ {
+ "0" "button_diamond active"
+ "1" "joystick active"
+ "2" "right_joystick active"
+ "3" "left_trigger active"
+ "4" "right_trigger active"
+ "5" "left_trackpad active"
+ "6" "right_trackpad active"
+ "7" "switch active"
+ }
+ }
+}
diff --git a/app/src/test/resources/sc/sc_touch_bind_test.vdf b/app/src/test/resources/sc/sc_touch_bind_test.vdf
new file mode 100644
index 0000000000..1d0e544ab9
--- /dev/null
+++ b/app/src/test/resources/sc/sc_touch_bind_test.vdf
@@ -0,0 +1,83 @@
+"controller_mappings"
+{
+ "version" "3"
+ "title" "SC Touch-Surface Binding Test"
+ "description" "Each surface's TOUCH fires a distinct key: left pad=1, right pad=2, left stick=3, right stick=4. Touch a surface (no click) -> its key fires while touched. Type into a text field to see it."
+ "controller_type" "controller_triton"
+ "group"
+ {
+ "id" "0"
+ "mode" "four_buttons"
+ "inputs"
+ {
+ "button_a" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button A" } } } }
+ "button_b" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button B" } } } }
+ "button_x" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button X" } } } }
+ "button_y" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button Y" } } } }
+ }
+ }
+ "group"
+ {
+ "id" "1"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ "click" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button JOYSTICK_LEFT" } } } }
+ "touch" { "activators" { "Full_Press" { "bindings" { "binding" "key_press 3" } } } }
+ }
+ }
+ "group"
+ {
+ "id" "2"
+ "mode" "joystick_move"
+ "inputs"
+ {
+ "click" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button JOYSTICK_RIGHT" } } } }
+ "touch" { "activators" { "Full_Press" { "bindings" { "binding" "key_press 4" } } } }
+ }
+ }
+ "group"
+ {
+ "id" "3"
+ "mode" "mouse"
+ "inputs"
+ {
+ "click" { "activators" { "Full_Press" { "bindings" { "binding" "mouse_button LEFT" } } } }
+ "touch" { "activators" { "Full_Press" { "bindings" { "binding" "key_press 1" } } } }
+ }
+ }
+ "group"
+ {
+ "id" "4"
+ "mode" "mouse"
+ "inputs"
+ {
+ "click" { "activators" { "Full_Press" { "bindings" { "binding" "mouse_button RIGHT" } } } }
+ "touch" { "activators" { "Full_Press" { "bindings" { "binding" "key_press 2" } } } }
+ }
+ }
+ "group"
+ {
+ "id" "5"
+ "mode" "switches"
+ "inputs"
+ {
+ "button_menu" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button START" } } } }
+ "button_escape" { "activators" { "Full_Press" { "bindings" { "binding" "xinput_button SELECT" } } } }
+ }
+ }
+ "preset"
+ {
+ "id" "0"
+ "name" "Default"
+ "group_source_bindings"
+ {
+ "0" "button_diamond active"
+ "1" "joystick active"
+ "2" "right_joystick active"
+ "3" "left_trackpad active"
+ "4" "right_trackpad active"
+ "5" "switch active"
+ }
+ }
+}
diff --git a/app/src/test/resources/sc/testgyro_gate_capture.vdf b/app/src/test/resources/sc/testgyro_gate_capture.vdf
new file mode 100644
index 0000000000..a066f5089d
--- /dev/null
+++ b/app/src/test/resources/sc/testgyro_gate_capture.vdf
@@ -0,0 +1,1613 @@
+"controller_mappings"
+{
+ "version" "3"
+ "revision" "182"
+ "title" "testGyroect1234"
+ "description" "Your modified layout for this game."
+ "creator" "76561198043339185"
+ "progenitor" ""
+ "url" "autosave://C:\\Program Files (x86)\\Steam\\steamapps\\common\\Steam Controller Configs\\83073457\\config\\551620\\controller_neptune.vdf"
+ "export_type" "personal_local"
+ "controller_type" "controller_triton"
+ "controller_caps" "1096925183"
+ "major_revision" "0"
+ "minor_revision" "0"
+ "Timestamp" "0"
+ "localization"
+ {
+ "english"
+ {
+ "title" "Gamepad With Joystick Trackpad"
+ "description" "This template is for most games that already have built-in gamepad support and have a first or third person controlled camera. FPS or Third Person Adventure games, etc."
+ }
+ "czech"
+ {
+ "title" "Gamepad s ovládáním kamery"
+ "description" "Tato šablona je pro většinu her podporujících gamepad a disponujících kamerou z pohledu první nebo třetí osoby. Mezi takové hry patří například akční hry z pohledu první nebo třetí osoby."
+ }
+ "danish"
+ {
+ "title" "Gamepad med kamerastyring"
+ "description" "Denne skabelon er til de fleste spil, der allerede har indbygget gamepad-understøttelse og har et første- eller tredjepersonskontrolleret kamera. FPS eller tredjepersons adventure-spil osv."
+ }
+ "dutch"
+ {
+ "title" "Gamepad met camerabesturing"
+ "description" "Deze template is voor de meeste spellen die reeds ingebouwde gamepadondersteuning hebben en die een camera hebben die wordt bestuurd in de eerste of derde persoon. FPS, third person-avontuurspellen, etc."
+ }
+ "finnish"
+ {
+ "title" "Kameraa ohjaava peliohjain"
+ "description" "Tämä malli on useimmille muita ohjaimia valmiiksi tukeville peleille, joissa on ensimmäisessä tai kolmannessa persoonassa ohjattava kamera. FPS-pelit, kolmannen persoonan seikkailupelit jne."
+ }
+ "french"
+ {
+ "title" "Manette avec contrôles caméra"
+ "description" "Ce modèle fonctionne pour la plupart des jeux ayant un support manette intégré et une caméra contrôlée à la première ou à la troisième personne. FPS, jeux d'aventure à la troisième personne, etc."
+ }
+ "german"
+ {
+ "title" "Gamepad mit Kamerasteuerung"
+ "description" "Diese Vorlage ist für die meisten Spiele konzipiert, die bereits volle Untersützung für Gamepads mit sich bringen und eine First- oder Third-Person-Kamerasteuerung haben. Gedacht für Ego-Shooter, Third-Person-Abenteuerspiele usw."
+ }
+ "hungarian"
+ {
+ "title" "Gamepad kamerairányítással"
+ "description" "Ez a sablon a legtöbb olyan játékhoz való, melyek már rendelkeznek beépített gamepad-támogatással, és van első vagy harmadik személyű kezelésű kamerájuk. Ilyenek az FPS vagy harmadik személyű kalandjátékok stb."
+ }
+ "italian"
+ {
+ "title" "Gamepad con controlli della telecamera"
+ "description" "Questo template è pensato per la maggior parte dei giochi che hanno già il supporto per gamepad integrato e hanno la visuale controllata in prima o terza persona. Giochi d'avventura in terza persona, FPS ecc."
+ }
+ "japanese"
+ {
+ "title" "カメラコントロール機能を持つゲームパッド"
+ "description" "FPS や、アドベンチャーゲームのような、一人称または三人称のカメラ操作を行うゲームパッドに標準対応したゲーム用のテンプレートです。"
+ }
+ "koreana"
+ {
+ "title" "카메라 조작 기능이 있는 게임패드"
+ "description" "이 템플릿은 이미 게임패드 지원이 내장되어 있으며 1인칭 또는 3인칭 시점 카메라 조작을 지원하는 대부분의 게임을 위한 것입니다. FPS, 3인칭 어드벤쳐 게임 및 기타."
+ }
+ "polish"
+ {
+ "title" "Kontroler obsługujący kamerę"
+ "description" "Ten szablon jest dla większości gier, które mają wbudowane wsparcie dla kontrolerów, a także kamerę kontrolowaną z perspektywy pierwszej lub trzeciej osoby, np. FPS-y bądź gry przygodowe."
+ }
+ "portuguese"
+ {
+ "title" "Comando com controlos de câmara"
+ "description" "Este modelo é indicado para jogos que já têm compatibilidade nativa com comando e têm uma câmara que pode ser controlada. Por exemplo, jogos em primeira ou terceira pessoa, do género de aventura, de tiros, etc."
+ }
+ "romanian"
+ {
+ "title" "Gamepad cu controale pentru cameră"
+ "description" "Acest șablon este pentru majoritatea jocurilor care au deja suport pentru gamepad implementat și au o cameră controlată din perspectivă first sau third person. FPS sau jocuri de aventură third person, etc."
+ }
+ "russian"
+ {
+ "title" "Геймпад с управлением камерой"
+ "description" "Этот шаблон предназначен для большинства игр от первого или третьего лица, в которых уже есть встроенная поддержка геймпада (например, для шутеров или экшенов)."
+ }
+ "spanish"
+ {
+ "title" "Mando con controles de cámara"
+ "description" "Esta plantilla es para la mayoría de juegos que ya incluyen de serie compatibilidad con mando y disponen de cámara controlada en primera o tercera persona: FPS, juegos de aventura en tercera persona, etc."
+ }
+ "swedish"
+ {
+ "title" "Gamepad med kamerakontroller"
+ "description" "Denna mall är för de flesta spel som redan har inbyggt stöd för spelkontroller och har en kamera som styrs i första- eller tredjeperson. FPS eller äventyrsspel etc."
+ }
+ "schinese"
+ {
+ "title" "支持视角控制的手柄"
+ "description" "该模板适用于已内置手柄支持,并且拥有第一或第三人称控制视角的大多数游戏。包括 FPS 或第三人称冒险游戏等。"
+ }
+ "thai"
+ {
+ "title" "เกมแพดพร้อมการควบคุมมุมกล้อง"
+ "description" "แม่แบบนี้ใช้สำหรับเกมส่วนมากที่มีการรองรับเกมแพดมาในตัวอยู่แล้ว และมีการควบคุมมุมกล้องในมุมมองบุคคลที่หนึ่งหรือสาม เช่น เกมยิงมุมมองบุคคลที่หนึ่ง หรือเกมผจญภัยมุมมองบุคคลที่สาม ฯลฯ"
+ }
+ "brazilian"
+ {
+ "title" "Controle com controle de câmera"
+ "description" "Este modelo é para jogos já compatíveis com controles que possuem uma câmera controlável, seja em primeira ou terceira pessoa, como jogos de tiro, aventura, etc."
+ }
+ "bulgarian"
+ {
+ "title" "Геймпад с управление на камерата"
+ "description" "Този шаблон е за повечето игри, които вече имат вградена поддръжка на геймпад и включват управление на камерата от първо или трето лице. Екшъни от първо лице, приключенски игри от трето лице и т.н."
+ }
+ "greek"
+ {
+ "title" "Χειριστήριο με πλήκτρα κάμερας"
+ "description" "Αυτό το πρότυπο είναι για τα περισσότερα παιχνίδια που έχουν ενσωματωμένη υποστήριξη χειριστηρίου και έχουν μια ελεγχόμενη κάμερα πρώτου ή τρίτου προσώπου. FPS ή παιχνίδια περιπέτειας τρίτου προσώπου κλπ."
+ }
+ "turkish"
+ {
+ "title" "Kamera Kontrollü Oyun Kumandası"
+ "description" "Bu şablon hali hazırda oyun içi oyun kumandası desteği ve birincil veya üçüncü kişi kontrollü kameraya sahip oyunlar içindir. FPS veya Üçüncü Kişi Macera oyunları vb."
+ }
+ "ukrainian"
+ {
+ "title" "Ґеймпад з елементами керування камерою"
+ "description" "Цей шаблон для більшості ігор, що вже мають вбудовану підтримку ґеймпада і у яких камера керується від першої або третьої особи. Шутери від першої особи чи пригодницькі ігри від третьої особи тощо."
+ }
+ }
+ "group"
+ {
+ "id" "0"
+ "mode" "four_buttons"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "button_a"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press LEFT_SHIFT, testMacro1, , "
+ }
+ "settings"
+ {
+ "delay_start" "76"
+ "delay_end" "308"
+ }
+ }
+ "Double_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button DPAD_RIGHT, , "
+ }
+ }
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button A, , "
+ }
+ }
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button DPAD_UP, , "
+ }
+ }
+ "release"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button DPAD_RIGHT, , "
+ }
+ }
+ "chord"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button DPAD_DOWN, , "
+ }
+ }
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button DPAD_LEFT, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_b"
+ {
+ "activators"
+ {
+ "Start_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button B, , "
+ "binding" "controller_action empty_sub_command, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "3"
+ "delay_start" "51"
+ "delay_end" "222"
+ "toggle" "1"
+ "cycle" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_x"
+ {
+ "activators"
+ {
+ "release"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button X, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ "delay_start" "215"
+ "delay_end" "14"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_y"
+ {
+ "activators"
+ {
+ "chord"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button A, , "
+ "binding" "controller_action empty_sub_command, , "
+ }
+ "settings"
+ {
+ "gyro_ratchet_button_mask" "270483205382143"
+ "gyro_ratchet_button_requireany_or_all" "1"
+ "hold_repeats" "1"
+ "haptic_intensity" "1"
+ "delay_start" "104"
+ "delay_end" "394"
+ "cycle" "1"
+ "toggle" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "1"
+ "mode" "dpad"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button dpad_up, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button dpad_down, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button dpad_right, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button dpad_left, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "2"
+ "mode" "joystick_move"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Soft_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button JOYSTICK_RIGHT, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "3"
+ "mode" "joystick_move"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button JOYSTICK_LEFT, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "deadzone_inner_radius" "7199"
+ }
+ }
+ "group"
+ {
+ "id" "4"
+ "mode" "trigger"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "output_trigger" "1"
+ }
+ }
+ "group"
+ {
+ "id" "5"
+ "mode" "trigger"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button Y, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ "delay_start" "1000"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "edge"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button B, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "output_trigger" "2"
+ "edge_binding_radius" "32767"
+ "adaptive_threshold" "2"
+ }
+ }
+ "group"
+ {
+ "id" "6"
+ "mode" "joystick_move"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Soft_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button JOYSTICK_RIGHT, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "8"
+ "mode" "joystick_move"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button JOYSTICK_RIGHT, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "9"
+ "mode" "dpad"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button DPAD_UP, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button DPAD_DOWN, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button DPAD_RIGHT, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button DPAD_LEFT, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ "layout" "3"
+ "haptic_intensity_override" "0"
+ }
+ }
+ "group"
+ {
+ "id" "10"
+ "mode" "single_button"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Soft_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button START, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "11"
+ "mode" "single_button"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Soft_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button SELECT, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ }
+ "group"
+ {
+ "id" "12"
+ "mode" "mouse_joystick"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Soft_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button JOYSTICK_RIGHT, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "mousejoystick_deadzone_x" "10006"
+ "custom_curve_exponent" "300"
+ }
+ }
+ "group"
+ {
+ "id" "13"
+ "mode" "flickstick"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button JOYSTICK_RIGHT, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "rotation_2" "-5"
+ "flickstick_rotation_sensitivity" "32000"
+ "flickstick_forward_deadzone_angle" "720"
+ "flickstick_snap_tightness" "0"
+ "flickstick_sweep_sensitivity" "48"
+ "flickstick_depression_speed" "80"
+ "flickstick_haptic_bump_per_angle" "0"
+ }
+ }
+ "group"
+ {
+ "id" "14"
+ "mode" "flickstick"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button JOYSTICK_LEFT, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "flickstick_rotation_sensitivity" "32000"
+ }
+ }
+ "group"
+ {
+ "id" "15"
+ "mode" "flickstick"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button JOYSTICK_RIGHT, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "flickstick_rotation_sensitivity" "32000"
+ }
+ }
+ "group"
+ {
+ "id" "16"
+ "mode" "flickstick"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button JOYSTICK_LEFT, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "flickstick_rotation_sensitivity" "32000"
+ }
+ }
+ "group"
+ {
+ "id" "18"
+ "mode" "mouse_region"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Soft_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button Y, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "output_joystick" "3"
+ "invert_x" "1"
+ "invert_y" "1"
+ "scale" "30"
+ "position_x" "24"
+ }
+ }
+ "group"
+ {
+ "id" "20"
+ "mode" "scrollwheel"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "22"
+ "mode" "dpad"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ }
+ }
+ "group"
+ {
+ "id" "23"
+ "mode" "mouse_region"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "output_joystick" "3"
+ "scale" "1"
+ "sensitivity_horiz_scale" "101"
+ }
+ }
+ "group"
+ {
+ "id" "24"
+ "mode" "joystick_mouse"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "output_joystick" "2"
+ "sensitivity" "283"
+ }
+ }
+ "group"
+ {
+ "id" "25"
+ "mode" "2dscroll"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "click"
+ {
+ "activators"
+ {
+ "Soft_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button JOYSTICK_RIGHT, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_north"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button A, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_south"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button B, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_east"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button X, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "dpad_west"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button Y, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "1"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "sensitivity" "98"
+ }
+ }
+ "group"
+ {
+ "id" "26"
+ "mode" "absolute_mouse"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "sensitivity" "3000"
+ "edge_spin_radius" "0"
+ "doubetap_max_duration" "500"
+ }
+ }
+ "group"
+ {
+ "id" "28"
+ "mode" "gyro_to_joystick"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "flickstick_rotation_sensitivity" "32000"
+ }
+ }
+ "group"
+ {
+ "id" "29"
+ "mode" "dpad"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ }
+ }
+ "group"
+ {
+ "id" "30"
+ "mode" "joystick_move"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "custom_curve_exponent" "375"
+ "edge_binding_radius" "32767"
+ "deadzone_outer_radius" "16015"
+ "deadzone_shape" "0"
+ "rotation_2" "180"
+ }
+ }
+ "group"
+ {
+ "id" "31"
+ "mode" "four_buttons"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "32"
+ "mode" "scrollwheel"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "scroll_angle" "180"
+ }
+ }
+ "group"
+ {
+ "id" "33"
+ "mode" "gyro_to_mouse"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "flickstick_rotation_sensitivity" "32000"
+ }
+ }
+ "group"
+ {
+ "id" "34"
+ "mode" "mouse_region"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "output_joystick" "3"
+ }
+ }
+ "group"
+ {
+ "id" "35"
+ "mode" "gyro_to_joystick_deflection"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "flickstick_rotation_sensitivity" "32000"
+ "gyro_to_2d_conversion_style" "1"
+ }
+ }
+ "group"
+ {
+ "id" "36"
+ "mode" "2dscroll"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ }
+ "group"
+ {
+ "id" "37"
+ "mode" "touch_menu"
+ "name" "test123"
+ "description" ""
+ "inputs"
+ {
+ "touch_menu_button_1"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 5, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_2"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 4, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_3"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press 2, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_4"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press P, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_5"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press L, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_6"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press M, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "touch_menu_button_7"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "key_press B, , "
+ }
+ "settings"
+ {
+ "haptic_intensity" "2"
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ "settings"
+ {
+ "gyro_button" "1"
+ "gyro_ratchet_button_mask" "1048576"
+ }
+ }
+ "group"
+ {
+ "id" "38"
+ "mode" "reference"
+ "description" ""
+ "settings"
+ {
+ "referenced_mode" "37"
+ }
+ }
+ "group"
+ {
+ "id" "39"
+ "mode" "dpad"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "requires_click" "0"
+ }
+ }
+ "group"
+ {
+ "id" "40"
+ "mode" "gyro_to_mouse"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ }
+ "settings"
+ {
+ "flickstick_rotation_sensitivity" "32000"
+ "gyro_ratchet_button_mask" "211106234105856"
+ }
+ }
+ "group"
+ {
+ "id" "7"
+ "mode" "switches"
+ "name" ""
+ "description" ""
+ "inputs"
+ {
+ "button_escape"
+ {
+ "activators"
+ {
+ "Long_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action dots_per_360_calibration_spin 360 250, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_menu"
+ {
+ "activators"
+ {
+ "Double_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action camera_reset 180 33 90, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "left_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action turn_to_face_direction joystick 0, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "right_bumper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button shoulder_right, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_back_left"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action turn_to_face_direction joystick 75, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_back_right"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action MOUSE_POSITION 22732 12757 1, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_back_left_upper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button JOYSTICK_RIGHT, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_back_right_upper"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button X, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_leftauxcapsense"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "xinput_button LSTICK_RIGHT, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ "button_rightauxcapsense"
+ {
+ "activators"
+ {
+ "Full_Press"
+ {
+ "bindings"
+ {
+ "binding" "controller_action MOUSE_POSITION 1 1 1, , "
+ }
+ }
+ }
+ "disabled_activators"
+ {
+ }
+ }
+ }
+ }
+ "preset"
+ {
+ "id" "0"
+ "name" "Default"
+ "group_source_bindings"
+ {
+ "7" "switch active"
+ "0" "button_diamond active"
+ "1" "left_trackpad inactive"
+ "11" "left_trackpad inactive"
+ "16" "left_trackpad inactive"
+ "20" "left_trackpad active"
+ "2" "right_trackpad inactive"
+ "6" "right_trackpad inactive"
+ "10" "right_trackpad inactive"
+ "12" "right_trackpad inactive"
+ "15" "right_trackpad inactive"
+ "18" "right_trackpad inactive"
+ "25" "right_trackpad active"
+ "26" "right_trackpad inactive"
+ "3" "joystick active"
+ "14" "joystick inactive"
+ "4" "left_trigger active"
+ "5" "right_trigger active"
+ "8" "right_joystick inactive"
+ "13" "right_joystick inactive"
+ "22" "right_joystick inactive"
+ "23" "right_joystick inactive"
+ "24" "right_joystick inactive"
+ "32" "right_joystick active"
+ "9" "dpad active"
+ "30" "dpad inactive"
+ "31" "dpad inactive"
+ "28" "gyro inactive"
+ "29" "gyro inactive"
+ "33" "gyro inactive"
+ "34" "gyro inactive"
+ "35" "gyro inactive"
+ "36" "gyro inactive"
+ "37" "gyro inactive"
+ "38" "gyro active"
+ "39" "gyro inactive modeshift"
+ "40" "gyro active modeshift"
+ }
+ }
+ "settings"
+ {
+ "left_trackpad_mode" "0"
+ "right_trackpad_mode" "0"
+ }
+}