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