refactor android input settings completely to enable per-game settings and saving in config.ini, as well as more efficient storage of setting information

This commit is contained in:
David Griswold 2026-03-18 12:19:25 +03:00
parent 7f94e80e8d
commit 0c6e7c6cc5
25 changed files with 1101 additions and 1067 deletions

View File

@ -234,6 +234,37 @@ if (ANDROID)
"android_hide_images"
"screen_orientation"
"performance_overlay_position"
"button_a"
"button_b"
"button_x"
"button_y"
"button_home"
"circlepad_up"
"circlepad_down"
"circlepad_left"
"circlepad_right"
"button_r"
"button_l"
"button_zr"
"button_zl"
"button_start"
"button_select"
"dpad_up"
"dpad_down"
"dpad_left"
"dpad_right"
"cstick_up"
"cstick_down"
"cstick_left"
"cstick_right"
"hotkey_cycle_layout"
"hotkey_close"
"hotkey_swap"
"hotkey_pause_resume"
"hotkey_quicksave"
"hotkey_turbo_limit"
"hotkey_quickload"
"hotkey_enable"
)
string(REPLACE "_" "_1" KEY_JNI_ESCAPED ${KEY})
set(SETTING_KEY_LIST "${SETTING_KEY_LIST}\n\"${KEY}\",")

View File

@ -959,6 +959,7 @@ object NativeLibrary {
const val DPAD = 780
const val BUTTON_DEBUG = 781
const val BUTTON_GPIO14 = 782
// TODO: replace these with Hotkey buttons - they aren't native!
const val BUTTON_SWAP = 800
const val BUTTON_TURBO = 801
}

View File

@ -7,7 +7,6 @@ package org.citra.citra_emu.activities
import android.Manifest.permission
import android.annotation.SuppressLint
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
@ -26,25 +25,23 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.navigation.fragment.NavHostFragment
import androidx.preference.PreferenceManager
import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.NativeLibrary.ButtonState
import org.citra.citra_emu.R
import org.citra.citra_emu.camera.StillImageCameraHelper.OnFilePickerResult
import org.citra.citra_emu.contracts.OpenFileResultContract
import org.citra.citra_emu.databinding.ActivityEmulationBinding
import org.citra.citra_emu.display.ScreenAdjustmentUtil
import org.citra.citra_emu.display.SecondaryDisplay
import org.citra.citra_emu.features.hotkeys.HotkeyUtility
import org.citra.citra_emu.features.input.GamepadHelper
import org.citra.citra_emu.features.input.HotkeyUtility
import org.citra.citra_emu.features.settings.model.BooleanSetting
import org.citra.citra_emu.features.settings.model.IntSetting
import org.citra.citra_emu.features.settings.model.Settings
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting
import org.citra.citra_emu.fragments.EmulationFragment
import org.citra.citra_emu.fragments.MessageDialogFragment
import org.citra.citra_emu.model.Game
import org.citra.citra_emu.utils.BuildUtil
import org.citra.citra_emu.utils.ControllerMappingHelper
import org.citra.citra_emu.utils.FileBrowserHelper
import org.citra.citra_emu.utils.EmulationLifecycleUtil
import org.citra.citra_emu.utils.EmulationMenuSettings
@ -53,10 +50,9 @@ import org.citra.citra_emu.utils.RefreshRateUtil
import org.citra.citra_emu.utils.ThemeUtil
import org.citra.citra_emu.viewmodel.EmulationViewModel
import org.citra.citra_emu.features.settings.utils.SettingsFile
import kotlin.math.abs
class EmulationActivity : AppCompatActivity() {
private val preferences: SharedPreferences
get() = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
var isActivityRecreated = false
val emulationViewModel: EmulationViewModel by viewModels()
private lateinit var binding: ActivityEmulationBinding
@ -288,13 +284,13 @@ class EmulationActivity : AppCompatActivity() {
return true
}
}
return hotkeyUtility.handleKeyPress(event)
return hotkeyUtility.handleKeyPress(if (event.keyCode == 0) event.scanCode else event.keyCode, event.device.descriptor)
}
KeyEvent.ACTION_UP -> {
return hotkeyUtility.handleKeyRelease(event)
return hotkeyUtility.handleKeyRelease(if (event.keyCode == 0) event.scanCode else event.keyCode, event.device.descriptor)
}
else -> {
return false;
return false
}
}
}
@ -314,7 +310,8 @@ class EmulationActivity : AppCompatActivity() {
// TODO: Move this check into native code - prevents crash if input pressed before starting emulation
if (!NativeLibrary.isRunning() ||
(event.source and InputDevice.SOURCE_CLASS_JOYSTICK == 0) ||
emulationFragment.isDrawerOpen()) {
emulationFragment.isDrawerOpen()
) {
return super.dispatchGenericMotionEvent(event)
}
@ -322,206 +319,76 @@ class EmulationActivity : AppCompatActivity() {
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
return true
}
val input = event.device
val motions = input.motionRanges
val axisValuesCirclePad = floatArrayOf(0.0f, 0.0f)
val axisValuesCStick = floatArrayOf(0.0f, 0.0f)
val axisValuesDPad = floatArrayOf(0.0f, 0.0f)
var isTriggerPressedLMapped = false
var isTriggerPressedRMapped = false
var isTriggerPressedZLMapped = false
var isTriggerPressedZRMapped = false
var isTriggerPressedL = false
var isTriggerPressedR = false
var isTriggerPressedZL = false
var isTriggerPressedZR = false
for (range in motions) {
val device = event.device
val manager = emulationViewModel.settings.inputMappingManager
val stickAccumulator = HashMap<Int, Pair<Float, Float>>()
for (range in device.motionRanges) {
val axis = range.axis
val origValue = event.getAxisValue(axis)
var value = ControllerMappingHelper.scaleAxis(input, axis, origValue)
val nextMapping =
preferences.getInt(InputBindingSetting.getInputAxisButtonKey(axis), -1)
val guestOrientation =
preferences.getInt(InputBindingSetting.getInputAxisOrientationKey(axis), -1)
val inverted = preferences.getBoolean(InputBindingSetting.getInputAxisInvertedKey(axis),false);
if (nextMapping == -1 || guestOrientation == -1) {
// Axis is unmapped
var value = GamepadHelper.scaleAxis(device, axis, origValue)
if (value > -0.1f && value < 0.1f) value = 0f
val axisPair = Pair(axis, if (value >= 0f) 1 else -1)
// special case where the axis value is 0 - we need to send releases to both directions
if (value == 0f) {
listOf(Pair(axis, 1), Pair(axis, -1)).forEach { zeroPair ->
manager.getOutAxesForAxis(zeroPair).forEach { (outAxis, outDir) ->
val component =
GamepadHelper.getJoystickComponent(outAxis) ?: return@forEach
val current =
stickAccumulator.getOrDefault(component.joystickType, Pair(0f, 0f))
stickAccumulator[component.joystickType] = if (component.isVertical) {
Pair(current.first, 0f)
} else {
Pair(0f, current.second)
}
}
manager.getOutButtonsForAxis(zeroPair).forEach { outButton ->
NativeLibrary.onGamePadEvent(
device.descriptor,
outButton,
ButtonState.RELEASED
)
}
}
continue
}
if (value > 0f && value < 0.1f || value < 0f && value > -0.1f) {
// Skip joystick wobble
value = 0f
// Axis to Axis mappings
manager.getOutAxesForAxis(axisPair).forEach { (outAxis, outDir) ->
val component = GamepadHelper.getJoystickComponent(outAxis) ?: return@forEach
val current =
stickAccumulator.getOrDefault(component.joystickType, Pair(0f, 0f))
val contribution = abs(value) * outDir
stickAccumulator[component.joystickType] = if (component.isVertical) {
Pair(current.first, contribution)
} else {
Pair(contribution, current.second)
}
}
if (inverted) value = -value;
when (nextMapping) {
NativeLibrary.ButtonType.STICK_LEFT -> {
axisValuesCirclePad[guestOrientation] = value
}
NativeLibrary.ButtonType.STICK_C -> {
axisValuesCStick[guestOrientation] = value
}
NativeLibrary.ButtonType.DPAD -> {
axisValuesDPad[guestOrientation] = value
}
NativeLibrary.ButtonType.TRIGGER_L -> {
isTriggerPressedLMapped = true
isTriggerPressedL = value != 0f
}
NativeLibrary.ButtonType.TRIGGER_R -> {
isTriggerPressedRMapped = true
isTriggerPressedR = value != 0f
}
NativeLibrary.ButtonType.BUTTON_ZL -> {
isTriggerPressedZLMapped = true
isTriggerPressedZL = value != 0f
}
NativeLibrary.ButtonType.BUTTON_ZR -> {
isTriggerPressedZRMapped = true
isTriggerPressedZR = value != 0f
// Axis to Button mappings
manager.getOutButtonsForAxis(axisPair).forEach { button ->
val mapping = manager.getMappingForButton(button)
if (abs(value) > (mapping?.threshold ?: 0.5f)) {
hotkeyUtility.handleKeyPress(button, device.descriptor)
NativeLibrary.onGamePadEvent(device.descriptor, button, ButtonState.PRESSED)
} else {
NativeLibrary.onGamePadEvent(
device.descriptor,
button,
ButtonState.RELEASED
)
}
}
}
// Circle-Pad and C-Stick status
NativeLibrary.onGamePadMoveEvent(
input.descriptor,
NativeLibrary.ButtonType.STICK_LEFT,
axisValuesCirclePad[0],
axisValuesCirclePad[1]
)
NativeLibrary.onGamePadMoveEvent(
input.descriptor,
NativeLibrary.ButtonType.STICK_C,
axisValuesCStick[0],
axisValuesCStick[1]
)
// Triggers L/R and ZL/ZR
if (isTriggerPressedLMapped) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.TRIGGER_L,
if (isTriggerPressedL) {
NativeLibrary.ButtonState.PRESSED
} else {
NativeLibrary.ButtonState.RELEASED
}
)
}
if (isTriggerPressedRMapped) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.TRIGGER_R,
if (isTriggerPressedR) {
NativeLibrary.ButtonState.PRESSED
} else {
NativeLibrary.ButtonState.RELEASED
}
)
}
if (isTriggerPressedZLMapped) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.BUTTON_ZL,
if (isTriggerPressedZL) {
NativeLibrary.ButtonState.PRESSED
} else {
NativeLibrary.ButtonState.RELEASED
}
)
}
if (isTriggerPressedZRMapped) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.BUTTON_ZR,
if (isTriggerPressedZR) {
NativeLibrary.ButtonState.PRESSED
} else {
NativeLibrary.ButtonState.RELEASED
}
)
stickAccumulator.forEach { (outAxis, value) ->
NativeLibrary.onGamePadMoveEvent(device.descriptor, outAxis, value.first, value.second)
}
// Work-around to allow D-pad axis to be bound to emulated buttons
if (axisValuesDPad[0] == 0f) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_LEFT,
NativeLibrary.ButtonState.RELEASED
)
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_RIGHT,
NativeLibrary.ButtonState.RELEASED
)
}
if (axisValuesDPad[0] < 0f) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_LEFT,
NativeLibrary.ButtonState.PRESSED
)
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_RIGHT,
NativeLibrary.ButtonState.RELEASED
)
}
if (axisValuesDPad[0] > 0f) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_LEFT,
NativeLibrary.ButtonState.RELEASED
)
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_RIGHT,
NativeLibrary.ButtonState.PRESSED
)
}
if (axisValuesDPad[1] == 0f) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_UP,
NativeLibrary.ButtonState.RELEASED
)
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_DOWN,
NativeLibrary.ButtonState.RELEASED
)
}
if (axisValuesDPad[1] < 0f) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_UP,
NativeLibrary.ButtonState.PRESSED
)
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_DOWN,
NativeLibrary.ButtonState.RELEASED
)
}
if (axisValuesDPad[1] > 0f) {
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_UP,
NativeLibrary.ButtonState.RELEASED
)
NativeLibrary.onGamePadEvent(
NativeLibrary.TouchScreenDevice,
NativeLibrary.ButtonType.DPAD_DOWN,
NativeLibrary.ButtonState.PRESSED
)
}
return true
}

View File

@ -0,0 +1,374 @@
package org.citra.citra_emu.features.input
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.R
import org.citra.citra_emu.features.settings.model.InputMappingSetting
import org.citra.citra_emu.features.settings.model.Settings
object GamepadHelper {
private const val BUTTON_NAME_L3 = "Button L3"
private const val BUTTON_NAME_R3 = "Button R3"
private const val NINTENDO_VENDOR_ID = 0x057e
// Linux BTN_DPAD_* values (0x220-0x223). Joy-Con D-pad buttons arrive as
// KEYCODE_UNKNOWN with these scan codes because Android's input layer doesn't
// translate them to KEYCODE_DPAD_*. translateEventToKeyId() falls back to
// the scan code in that case.
private const val LINUX_BTN_DPAD_UP = 0x220 // 544
private const val LINUX_BTN_DPAD_DOWN = 0x221 // 545
private const val LINUX_BTN_DPAD_LEFT = 0x222 // 546
private const val LINUX_BTN_DPAD_RIGHT = 0x223 // 547
private val buttonNameOverrides = mapOf(
KeyEvent.KEYCODE_BUTTON_THUMBL to BUTTON_NAME_L3,
KeyEvent.KEYCODE_BUTTON_THUMBR to BUTTON_NAME_R3,
LINUX_BTN_DPAD_UP to "Dpad Up",
LINUX_BTN_DPAD_DOWN to "Dpad Down",
LINUX_BTN_DPAD_LEFT to "Dpad Left",
LINUX_BTN_DPAD_RIGHT to "Dpad Right"
)
fun getButtonName(keyCode: Int): String =
buttonNameOverrides[keyCode]
?: toTitleCase(KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_"))
fun getAxisName(axis: Int, direction: Int?): String = "Axis " + axis + direction?.let { if(it > 0) "+" else "-" }
private fun toTitleCase(raw: String): String =
raw.replace("_", " ").lowercase()
.split(" ").joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }
private data class DefaultButtonMapping(
val setting: InputMappingSetting,
val hostKeyCode: Int
)
// Auto-map always sets inverted = false. Users needing inverted axes should remap manually.
private data class DefaultAxisMapping(
val setting: InputMappingSetting,
val hostAxis: Int,
val hostDirection: Int
)
private val xboxFaceButtonMappings = listOf(
DefaultButtonMapping(InputMappingSetting.BUTTON_A, KeyEvent.KEYCODE_BUTTON_B),
DefaultButtonMapping(InputMappingSetting.BUTTON_B, KeyEvent.KEYCODE_BUTTON_A),
DefaultButtonMapping(InputMappingSetting.BUTTON_X, KeyEvent.KEYCODE_BUTTON_Y),
DefaultButtonMapping(InputMappingSetting.BUTTON_Y, KeyEvent.KEYCODE_BUTTON_X)
)
private val nintendoFaceButtonMappings = listOf(
DefaultButtonMapping(InputMappingSetting.BUTTON_A, KeyEvent.KEYCODE_BUTTON_A),
DefaultButtonMapping(InputMappingSetting.BUTTON_B, KeyEvent.KEYCODE_BUTTON_B),
DefaultButtonMapping(InputMappingSetting.BUTTON_X, KeyEvent.KEYCODE_BUTTON_X),
DefaultButtonMapping(InputMappingSetting.BUTTON_Y, KeyEvent.KEYCODE_BUTTON_Y)
)
private val commonButtonMappings = listOf(
DefaultButtonMapping(InputMappingSetting.BUTTON_L, KeyEvent.KEYCODE_BUTTON_L1),
DefaultButtonMapping(InputMappingSetting.BUTTON_R, KeyEvent.KEYCODE_BUTTON_R1),
DefaultButtonMapping(InputMappingSetting.BUTTON_ZL, KeyEvent.KEYCODE_BUTTON_L2),
DefaultButtonMapping(InputMappingSetting.BUTTON_ZR, KeyEvent.KEYCODE_BUTTON_R2),
DefaultButtonMapping(InputMappingSetting.BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_SELECT),
DefaultButtonMapping(InputMappingSetting.BUTTON_START, KeyEvent.KEYCODE_BUTTON_START)
)
private val dpadButtonMappings = listOf(
DefaultButtonMapping(InputMappingSetting.DPAD_UP, KeyEvent.KEYCODE_DPAD_UP),
DefaultButtonMapping(InputMappingSetting.DPAD_DOWN, KeyEvent.KEYCODE_DPAD_DOWN),
DefaultButtonMapping(InputMappingSetting.DPAD_LEFT, KeyEvent.KEYCODE_DPAD_LEFT),
DefaultButtonMapping(InputMappingSetting.DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_RIGHT)
)
private val stickAxisMappings = listOf(
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_LEFT, MotionEvent.AXIS_X, -1),
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_RIGHT, MotionEvent.AXIS_X, 1),
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_UP, MotionEvent.AXIS_Y, -1),
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_DOWN, MotionEvent.AXIS_Y, 1),
DefaultAxisMapping(InputMappingSetting.CSTICK_LEFT, MotionEvent.AXIS_Z, -1),
DefaultAxisMapping(InputMappingSetting.CSTICK_RIGHT, MotionEvent.AXIS_Z, 1),
DefaultAxisMapping(InputMappingSetting.CSTICK_UP, MotionEvent.AXIS_RZ, -1),
DefaultAxisMapping(InputMappingSetting.CSTICK_DOWN, MotionEvent.AXIS_RZ, 1)
)
private val dpadAxisMappings = listOf(
DefaultAxisMapping(InputMappingSetting.DPAD_UP, MotionEvent.AXIS_HAT_Y, -1),
DefaultAxisMapping(InputMappingSetting.DPAD_DOWN, MotionEvent.AXIS_HAT_Y, 1),
DefaultAxisMapping(InputMappingSetting.DPAD_LEFT, MotionEvent.AXIS_HAT_X, -1),
DefaultAxisMapping(InputMappingSetting.DPAD_RIGHT, MotionEvent.AXIS_HAT_X, 1)
)
// Nintendo Switch Joy-Con specific mappings.
// Joy-Cons connected via Bluetooth on Android have several quirks:
// - They register as two separate InputDevices (left and right)
// - Android's evdev translation swaps A<->B (BTN_EAST->BUTTON_B, BTN_SOUTH->BUTTON_A)
// but does NOT swap X<->Y (BTN_NORTH->BUTTON_X, BTN_WEST->BUTTON_Y)
// - D-pad buttons arrive as KEYCODE_UNKNOWN (0) with Linux BTN_DPAD_* scan codes
// - Right stick uses AXIS_RX/AXIS_RY instead of AXIS_Z/AXIS_RZ
// Joy-Con face buttons: A/B are swapped by Android's evdev layer, but X/Y are not.
// This is different from both the standard Xbox table (full swap) and the
// Nintendo table (no swap).
private val joyconFaceButtonMappings = listOf(
DefaultButtonMapping(InputMappingSetting.BUTTON_A, KeyEvent.KEYCODE_BUTTON_B),
DefaultButtonMapping(InputMappingSetting.BUTTON_B, KeyEvent.KEYCODE_BUTTON_A),
DefaultButtonMapping(InputMappingSetting.BUTTON_X, KeyEvent.KEYCODE_BUTTON_X),
DefaultButtonMapping(InputMappingSetting.BUTTON_Y, KeyEvent.KEYCODE_BUTTON_Y)
)
// Joy-Con D-pad: uses Linux scan codes because Android reports BTN_DPAD_* as KEYCODE_UNKNOWN
private val joyconDpadButtonMappings = listOf(
DefaultButtonMapping(InputMappingSetting.DPAD_UP, LINUX_BTN_DPAD_UP),
DefaultButtonMapping(InputMappingSetting.DPAD_DOWN, LINUX_BTN_DPAD_DOWN),
DefaultButtonMapping(InputMappingSetting.DPAD_LEFT, LINUX_BTN_DPAD_LEFT),
DefaultButtonMapping(InputMappingSetting.DPAD_RIGHT, LINUX_BTN_DPAD_RIGHT)
)
// Joy-Con sticks: left stick is AXIS_X/Y (standard), right stick is AXIS_RX/RY
// (not Z/RZ like most controllers). The horizontal axis is inverted relative to
// the standard orientation - verified empirically on paired Joy-Cons via Bluetooth.
private val joyconStickAxisMappings = listOf(
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_UP, MotionEvent.AXIS_Y, -1),
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_DOWN, MotionEvent.AXIS_Y, 1),
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_LEFT, MotionEvent.AXIS_X, -1),
DefaultAxisMapping(InputMappingSetting.CIRCLEPAD_RIGHT, MotionEvent.AXIS_X, 1),
DefaultAxisMapping(InputMappingSetting.CSTICK_UP, MotionEvent.AXIS_RY, -1),
DefaultAxisMapping(InputMappingSetting.CSTICK_DOWN, MotionEvent.AXIS_RY, 1),
DefaultAxisMapping(InputMappingSetting.CSTICK_LEFT, MotionEvent.AXIS_RX, 1),
DefaultAxisMapping(InputMappingSetting.CSTICK_RIGHT, MotionEvent.AXIS_RX, -1)
)
/**
* Detects whether a device is a Nintendo Switch Joy-Con (as opposed to a
* Pro Controller or other Nintendo device) by checking vendor ID + device
* capabilities. Joy-Cons lack AXIS_HAT_X/Y and use AXIS_RX/RY for the
* right stick, while the Pro Controller has standard HAT axes and Z/RZ.
*/
fun isJoyCon(device: InputDevice?): Boolean {
if (device == null) return false
if (device.vendorId != NINTENDO_VENDOR_ID) return false
// Pro Controllers have HAT_X/HAT_Y (D-pad) and Z/RZ (right stick).
// Joy-Cons lack both: no HAT axes, right stick on RX/RY instead of Z/RZ.
var hasHatAxes = false
var hasStandardRightStick = false
for (range in device.motionRanges) {
when (range.axis) {
MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y -> hasHatAxes = true
MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ -> hasStandardRightStick = true
}
}
return !hasHatAxes && !hasStandardRightStick
}
fun clearAllBindings(settings: Settings) {
InputMappingSetting.values().forEach { settings.set(it, Input()) }
}
private fun applyBindings(
settings: Settings,
buttonMappings: List<DefaultButtonMapping>,
axisMappings: List<DefaultAxisMapping>
) {
buttonMappings.forEach { mapping ->
settings.set(mapping.setting, Input(key = mapping.hostKeyCode))
}
axisMappings.forEach { mapping ->
settings.set(
mapping.setting,
Input(
axis = mapping.hostAxis,
direction = mapping.hostDirection
)
)
}
}
/**
* Applies Joy-Con specific bindings: scan code D-pad, partial face button
* swap, and AXIS_RX/RY right stick.
*/
fun applyJoyConBindings(settings: Settings) {
applyBindings(
settings,
joyconFaceButtonMappings + commonButtonMappings + joyconDpadButtonMappings,
joyconStickAxisMappings
)
}
/**
* Applies auto-mapped bindings based on detected controller layout and d-pad type.
*
* @param isNintendoLayout true if the controller uses Nintendo face button layout
* (A=east, B=south), false for Xbox layout (A=south, B=east)
* @param useAxisDpad true if the d-pad should be mapped as axis (HAT_X/HAT_Y),
* false if it should be mapped as individual button keycodes (DPAD_UP/DOWN/LEFT/RIGHT)
*/
fun applyAutoMapBindings(settings: Settings, isNintendoLayout: Boolean, useAxisDpad: Boolean) {
val faceButtons = if (isNintendoLayout) nintendoFaceButtonMappings else xboxFaceButtonMappings
val buttonMappings = if (useAxisDpad) {
faceButtons + commonButtonMappings
} else {
faceButtons + commonButtonMappings + dpadButtonMappings
}
val axisMappings = if (useAxisDpad) {
stickAxisMappings + dpadAxisMappings
} else {
stickAxisMappings
}
applyBindings(settings,buttonMappings, axisMappings)
}
/**
* Some controllers report extra button presses that can be ignored.
*/
fun shouldKeyBeIgnored(inputDevice: InputDevice, keyCode: Int): Boolean {
return if (isDualShock4(inputDevice)) {
// The two analog triggers generate analog motion events as well as a keycode.
// We always prefer to use the analog values, so throw away the button press
keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2
} else false
}
/**
* Scale an axis to be zero-centered with a proper range.
*/
fun scaleAxis(inputDevice: InputDevice, axis: Int, value: Float): Float {
if (isDualShock4(inputDevice)) {
// Android doesn't have correct mappings for this controller's triggers. It reports them
// as RX & RY, centered at -1.0, and with a range of [-1.0, 1.0]
// Scale them to properly zero-centered with a range of [0.0, 1.0].
if (axis == MotionEvent.AXIS_RX || axis == MotionEvent.AXIS_RY) {
return (value + 1) / 2.0f
}
} else if (isXboxOneWireless(inputDevice)) {
// Same as the DualShock 4, the mappings are missing.
if (axis == MotionEvent.AXIS_Z || axis == MotionEvent.AXIS_RZ) {
return (value + 1) / 2.0f
}
if (axis == MotionEvent.AXIS_GENERIC_1) {
// This axis is stuck at ~.5. Ignore it.
return 0.0f
}
} else if (isMogaPro2Hid(inputDevice)) {
// This controller has a broken axis that reports a constant value. Ignore it.
if (axis == MotionEvent.AXIS_GENERIC_1) {
return 0.0f
}
}
return value
}
private fun isDualShock4(inputDevice: InputDevice): Boolean {
// Sony DualShock 4 controller
return inputDevice.vendorId == 0x54c && inputDevice.productId == 0x9cc
}
private fun isXboxOneWireless(inputDevice: InputDevice): Boolean {
// Microsoft Xbox One controller
return inputDevice.vendorId == 0x45e && inputDevice.productId == 0x2e0
}
private fun isMogaPro2Hid(inputDevice: InputDevice): Boolean {
// Moga Pro 2 HID
return inputDevice.vendorId == 0x20d6 && inputDevice.productId == 0x6271
}
data class JoystickComponent(val joystickType: Int, val isVertical: Boolean)
fun getJoystickComponent(buttonType: Int): JoystickComponent? = when (buttonType) {
NativeLibrary.ButtonType.STICK_LEFT_UP,
NativeLibrary.ButtonType.STICK_LEFT_DOWN -> JoystickComponent(NativeLibrary.ButtonType.STICK_LEFT, true)
NativeLibrary.ButtonType.STICK_LEFT_LEFT,
NativeLibrary.ButtonType.STICK_LEFT_RIGHT -> JoystickComponent(NativeLibrary.ButtonType.STICK_LEFT, false)
NativeLibrary.ButtonType.STICK_C_UP,
NativeLibrary.ButtonType.STICK_C_DOWN -> JoystickComponent(NativeLibrary.ButtonType.STICK_C, true)
NativeLibrary.ButtonType.STICK_C_LEFT,
NativeLibrary.ButtonType.STICK_C_RIGHT -> JoystickComponent(NativeLibrary.ButtonType.STICK_C, false)
else -> null
}
val buttonKeys = listOf(
InputMappingSetting.BUTTON_A,
InputMappingSetting.BUTTON_B,
InputMappingSetting.BUTTON_X,
InputMappingSetting.BUTTON_Y,
InputMappingSetting.BUTTON_SELECT,
InputMappingSetting.BUTTON_START,
InputMappingSetting.BUTTON_HOME
)
val buttonTitles = listOf(
R.string.button_a,
R.string.button_b,
R.string.button_x,
R.string.button_y,
R.string.button_select,
R.string.button_start,
R.string.button_home
)
val circlePadKeys = listOf(
InputMappingSetting.CIRCLEPAD_UP,
InputMappingSetting.CIRCLEPAD_DOWN,
InputMappingSetting.CIRCLEPAD_LEFT,
InputMappingSetting.CIRCLEPAD_RIGHT
)
val cStickKeys = listOf(
InputMappingSetting.CSTICK_UP,
InputMappingSetting.CSTICK_DOWN,
InputMappingSetting.CSTICK_LEFT,
InputMappingSetting.CSTICK_RIGHT
)
val dPadButtonKeys = listOf(
InputMappingSetting.DPAD_UP,
InputMappingSetting.DPAD_DOWN,
InputMappingSetting.DPAD_LEFT,
InputMappingSetting.DPAD_RIGHT
)
val dPadTitles = listOf(
R.string.direction_up,
R.string.direction_down,
R.string.direction_left,
R.string.direction_right
)
val axisTitles = dPadTitles
val triggerKeys = listOf(
InputMappingSetting.BUTTON_L,
InputMappingSetting.BUTTON_R,
InputMappingSetting.BUTTON_ZL,
InputMappingSetting.BUTTON_ZR
)
val triggerTitles = listOf(
R.string.button_l,
R.string.button_r,
R.string.button_zl,
R.string.button_zr
)
val hotKeys = listOf(
InputMappingSetting.HOTKEY_ENABLE,
InputMappingSetting.HOTKEY_SWAP,
InputMappingSetting.HOTKEY_CYCLE_LAYOUT,
InputMappingSetting.HOTKEY_CLOSE_GAME,
InputMappingSetting.HOTKEY_PAUSE_OR_RESUME,
InputMappingSetting.HOTKEY_QUICKSAVE,
InputMappingSetting.HOTKEY_QUICKLOAD,
InputMappingSetting.HOTKEY_TURBO_LIMIT
)
val hotkeyTitles = listOf(
R.string.controller_hotkey_enable_button,
R.string.emulation_swap_screens,
R.string.emulation_cycle_landscape_layouts,
R.string.emulation_close_game,
R.string.emulation_toggle_pause,
R.string.emulation_quicksave,
R.string.emulation_quickload,
R.string.turbo_limit_hotkey
)
}

View File

@ -2,7 +2,7 @@
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.features.hotkeys
package org.citra.citra_emu.features.input
enum class Hotkey(val button: Int) {
SWAP_SCREEN(10001),

View File

@ -2,7 +2,7 @@
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.features.hotkeys
package org.citra.citra_emu.features.input
import android.content.Context
import android.view.KeyEvent
@ -16,6 +16,7 @@ import org.citra.citra_emu.utils.TurboHelper
import org.citra.citra_emu.display.ScreenAdjustmentUtil
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting
import org.citra.citra_emu.features.settings.model.Settings
import kotlin.math.abs
class HotkeyUtility(
private val screenAdjustmentUtil: ScreenAdjustmentUtil,
@ -27,17 +28,18 @@ class HotkeyUtility(
private var hotkeyIsEnabled = false
var hotkeyIsPressed = false
private val currentlyPressedButtons = mutableSetOf<Int>()
/** Store which axis directions are currently pressed as (axis, direction) pairs. */
private val pressedAxisDirections = HashSet<Pair<Int, Int>>() // (outAxis, outDir)
fun handleKeyPress(keyEvent: KeyEvent): Boolean {
fun handleKeyPress(key: Int, descriptor: String): Boolean {
var handled = false
val buttonSet = InputBindingSetting.getButtonSet(keyEvent)
val enableButton =
PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
.getString(Settings.HOTKEY_ENABLE, "")
val buttonSet = settings.inputMappingManager.getOutButtonsForKey(key)
val axisSet = settings.inputMappingManager.getOutAxesForKey(key)
val enableButtonMapped = settings.inputMappingManager.getMappingForButton(Hotkey.ENABLE.button) != null
val thisKeyIsEnableButton = buttonSet.contains(Hotkey.ENABLE.button)
val thisKeyIsHotkey =
!thisKeyIsEnableButton && Hotkey.entries.any { buttonSet.contains(it.button) }
hotkeyIsEnabled = hotkeyIsEnabled || enableButton == "" || thisKeyIsEnableButton
hotkeyIsEnabled = hotkeyIsEnabled || !enableButtonMapped || thisKeyIsEnableButton
// Now process all internal buttons associated with this keypress
for (button in buttonSet) {
@ -58,19 +60,23 @@ class HotkeyUtility(
// the normal key event.
if (!thisKeyIsHotkey || !hotkeyIsEnabled) {
handled = NativeLibrary.onGamePadEvent(
keyEvent.device.descriptor,
descriptor,
button,
NativeLibrary.ButtonState.PRESSED
) || handled
}
}
}
// Handle axes in helper functions
updateAxisStateForKey(axisSet,true)
handled = sendAxisState(descriptor, axisSet) || handled
return handled
}
fun handleKeyRelease(keyEvent: KeyEvent): Boolean {
fun handleKeyRelease(key: Int, descriptor: String): Boolean {
var handled = false
val buttonSet = InputBindingSetting.getButtonSet(keyEvent)
val buttonSet = settings.inputMappingManager.getOutButtonsForKey(key)
val axisSet = settings.inputMappingManager.getOutAxesForKey(key)
val thisKeyIsEnableButton = buttonSet.contains(Hotkey.ENABLE.button)
val thisKeyIsHotkey =
!thisKeyIsEnableButton && Hotkey.entries.any { buttonSet.contains(it.button) }
@ -96,7 +102,7 @@ class HotkeyUtility(
)
) {
handled = NativeLibrary.onGamePadEvent(
keyEvent.device.descriptor,
descriptor,
button,
NativeLibrary.ButtonState.RELEASED
) || handled
@ -104,6 +110,8 @@ class HotkeyUtility(
}
}
}
updateAxisStateForKey(axisSet,false)
handled = sendAxisState(descriptor, axisSet) || handled
return handled
}
@ -142,4 +150,42 @@ class HotkeyUtility(
hotkeyIsPressed = true
return true
}
private fun updateAxisStateForKey(axisSet: List<Pair<Int, Int>>, pressed: Boolean) {
axisSet.forEach { (outAxis, outDir) ->
if (pressed) pressedAxisDirections.add(Pair(outAxis, outDir))
else pressedAxisDirections.remove(Pair(outAxis, outDir))
}
}
/**
* Update axis state based on currently pressed buttons
*/
private fun sendAxisState(descriptor: String, affectedAxes: List<Pair<Int, Int>>): Boolean {
val stickAccumulator = HashMap<Int, Pair<Float, Float>>()
// to make sure that when both directions are released, we still get a return to 0, but only for
// axes that have been touched by buttons
affectedAxes.forEach { (outAxis, outDir) ->
val component = GamepadHelper.getJoystickComponent(outAxis) ?: return@forEach
stickAccumulator.putIfAbsent(component.joystickType, Pair(0f,0f))
}
pressedAxisDirections.forEach{ (outAxis, outDir) ->
val component = GamepadHelper.getJoystickComponent(outAxis) ?: return@forEach
val current =
stickAccumulator.getOrDefault(component.joystickType, Pair(0f, 0f))
// if opposite directions of the same stick are held, this will let them cancel each other out
stickAccumulator[component.joystickType] = if (component.isVertical) {
Pair(current.first, current.second + outDir)
} else {
Pair(current.first + outDir, current.second)
}
}
var handled = false
stickAccumulator.forEach { (joystickType, value) ->
handled = NativeLibrary.onGamePadMoveEvent(descriptor, joystickType, value.first, value.second) || handled
}
return handled
}
}

View File

@ -0,0 +1,16 @@
package org.citra.citra_emu.features.input
/**
* Stores information about a particular input
*/
data class Input(
val key: Int? = null,
val axis: Int? = null,
// +1 or -1
val direction: Int? = null,
val threshold: Float? = null
) {
val empty: Boolean
get() = key == null && axis == null
}

View File

@ -0,0 +1,149 @@
package org.citra.citra_emu.features.input
import org.citra.citra_emu.features.settings.model.InputMappingSetting
import org.citra.citra_emu.features.settings.model.Settings
class InputMappingManager() {
/**
* input keys are represented by a single int, and can be mapped to 3ds buttons or axis directions
* input axes are represented by an <axis, direction> pair, where direction is either 1 or -1
* and can be mapped to output axes or an output button
* output keys are a single int
* output axes are also a pair
*
* For now, we are only allowing one output *axis* per input, but the code is designed to change
* that if necessary. There can be more than on output *button* per input because hotkeys are treated
* as buttons and it is possible to map an input to both a 3ds button and a hotkey.
*/
private val keyToOutButtons = HashMap<Int, MutableList<Int>>()
private val keyToOutAxes = HashMap<Int, MutableList<Pair<Int, Int>>>()
private val axisToOutAxes = HashMap<Pair<Int, Int>, MutableList<Pair<Int, Int>>>()
private val axisToOutButtons = HashMap<Pair<Int, Int>, MutableList<Int>>()
private val outAxisToMapping = HashMap<Pair<Int, Int>, Input>()
private val buttonToMapping = HashMap<Int, Input>()
/** Rebuilds the input maps from the given settings instance */
fun rebuild(settings: Settings) {
clear()
InputMappingSetting.values().forEach { setting ->
val mapping = settings.get(setting) ?: return@forEach
register(setting, mapping)
}
}
fun clear() {
axisToOutButtons.clear()
buttonToMapping.clear()
axisToOutAxes.clear()
outAxisToMapping.clear()
keyToOutButtons.clear()
keyToOutAxes.clear()
}
/** Rebind a particular setting */
fun rebind(setting: InputMappingSetting, newMapping: Input?) {
clearMapping(setting)
if (newMapping != null) register(setting, newMapping)
}
/** Clear a mapping from all hashmaps */
fun clearMapping(setting: InputMappingSetting) {
val outPair = if (setting.outAxis != null && setting.outDirection != null) Pair(
setting.outAxis,
setting.outDirection
) else null
val oldMapping = if (setting.outKey != null) {
buttonToMapping.get(setting.outKey)
} else if (setting.outAxis != null && setting.outDirection != null) {
outAxisToMapping.get(Pair(setting.outAxis, setting.outDirection))
} else {
null
}
val oldPair = if (oldMapping?.axis != null && oldMapping?.direction != null) Pair(
oldMapping.axis,
oldMapping.direction
) else null
// if our old mapping was a key, remove its binds
if (oldMapping?.key != null) {
keyToOutButtons[oldMapping.key]?.remove(setting.outKey)
if (outPair != null) {
keyToOutAxes[oldMapping.key]?.remove(outPair)
}
}
// if our old mapping was an axis, remove its binds
if (oldPair != null) {
if (setting.outAxis != null && setting.outDirection != null)
axisToOutAxes[oldPair]?.remove(outPair)
if (setting.outKey != null)
axisToOutButtons[oldPair]?.remove(setting.outKey)
}
// remove the reverse binds
if (setting.outKey != null) {
buttonToMapping.remove(setting.outKey)
}
if (outPair != null) {
outAxisToMapping.remove(outPair)
}
}
/**
* Add a single item to the maps based on the value of the InputMapping and the InputMappingSetting
*/
private fun register(setting: InputMappingSetting, mapping: Input) {
val outPair = if (setting.outAxis != null && setting.outDirection != null) Pair(
setting.outAxis,
setting.outDirection
) else null
val inPair = if (mapping.axis != null && mapping.direction != null) Pair(
mapping.axis,
mapping.direction
) else null
if (setting.outKey != null) {
if (mapping.key != null)
keyToOutButtons.getOrPut(mapping.key, { mutableListOf() }).add(setting.outKey)
if (inPair != null) {
if (setting.outKey != null)
axisToOutButtons.getOrPut(inPair, { mutableListOf() }).add(setting.outKey)
}
buttonToMapping[setting.outKey] = mapping
}
if (outPair != null) {
if (mapping.key != null)
keyToOutAxes.getOrPut(mapping.key, { mutableListOf() })
.add(outPair)
if (inPair != null)
axisToOutAxes.getOrPut(inPair, { mutableListOf() })
.add(outPair)
outAxisToMapping[outPair] = mapping
}
}
fun getOutAxesForAxis(pair: Pair<Int, Int>): List<Pair<Int, Int>> =
axisToOutAxes[pair] ?: emptyList()
fun getOutButtonsForAxis(pair: Pair<Int, Int>): List<Int> =
axisToOutButtons[pair] ?: emptyList()
fun getMappingForOutAxis(pair: Pair<Int, Int>): Input? =
outAxisToMapping[pair]
fun getOutButtonsForKey(keyCode: Int): List<Int> =
keyToOutButtons[keyCode] ?: emptyList()
fun getOutAxesForKey(keyCode: Int): List<Pair<Int, Int>> =
keyToOutAxes[keyCode] ?: emptyList()
fun getMappingForButton(outKey: Int): Input? =
buttonToMapping[outKey]
}

View File

@ -138,4 +138,38 @@ object SettingKeys {
external fun android_hide_images(): String
external fun screen_orientation(): String
external fun performance_overlay_position(): String
external fun button_a(): String
external fun button_b(): String
external fun button_x(): String
external fun button_y(): String
external fun button_home(): String
external fun circlepad_up(): String
external fun circlepad_down(): String
external fun circlepad_left(): String
external fun circlepad_right(): String
external fun button_r(): String
external fun button_l(): String
external fun button_zr(): String
external fun button_zl(): String
external fun button_start(): String
external fun button_select(): String
external fun dpad_up(): String
external fun dpad_down(): String
external fun dpad_left(): String
external fun dpad_right(): String
external fun cstick_up(): String
external fun cstick_down(): String
external fun cstick_left(): String
external fun cstick_right(): String
external fun hotkey_cycle_layout(): String
external fun hotkey_close(): String
external fun hotkey_swap(): String
external fun hotkey_pause_resume(): String
external fun hotkey_quicksave(): String
external fun hotkey_turbo_limit(): String
external fun hotkey_quickload(): String
external fun hotkey_enable(): String
}

View File

@ -0,0 +1,221 @@
// Copyright Citra Emulator Project / Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.features.settings.model
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.features.input.Input
import org.citra.citra_emu.features.input.GamepadHelper
import org.citra.citra_emu.features.input.Hotkey
import org.citra.citra_emu.features.settings.SettingKeys
enum class InputMappingSetting(
override val key: String,
override val section: String,
override val defaultValue: Input = Input(),
val outKey: Int? = null,
val outAxis: Int? = null,
val outDirection: Int? = null
) : AbstractSetting<Input?> {
BUTTON_A(
SettingKeys.button_a(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_A
),
BUTTON_B(
SettingKeys.button_b(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_B
),
BUTTON_X(
SettingKeys.button_x(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_X
),
BUTTON_Y(
SettingKeys.button_y(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_Y
),
BUTTON_HOME(
SettingKeys.button_home(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_HOME
),
BUTTON_L(
SettingKeys.button_l(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.TRIGGER_L
),
BUTTON_R(
SettingKeys.button_r(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.TRIGGER_R
),
BUTTON_SELECT(
SettingKeys.button_select(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_SELECT
),
BUTTON_START(
SettingKeys.button_start(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_START
),
BUTTON_ZL(
SettingKeys.button_zl(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_ZL
),
BUTTON_ZR(
SettingKeys.button_zr(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.BUTTON_ZR
),
DPAD_UP(
SettingKeys.dpad_up(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.DPAD_UP
),
DPAD_DOWN(
SettingKeys.dpad_down(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.DPAD_DOWN
),
DPAD_LEFT(
SettingKeys.dpad_left(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.DPAD_LEFT
),
DPAD_RIGHT(
SettingKeys.dpad_right(),
Settings.SECTION_CONTROLS,
outKey = NativeLibrary.ButtonType.DPAD_RIGHT
),
CIRCLEPAD_UP(
SettingKeys.circlepad_up(),
Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_LEFT_UP,
outDirection = -1
),
CIRCLEPAD_DOWN(
SettingKeys.circlepad_down(), Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_LEFT_DOWN, outDirection = 1
),
CIRCLEPAD_LEFT(
SettingKeys.circlepad_left(), Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_LEFT_LEFT, outDirection = -1
),
CIRCLEPAD_RIGHT(
SettingKeys.circlepad_right(), Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_LEFT_RIGHT, outDirection = 1
),
CSTICK_UP(
SettingKeys.cstick_up(),
Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_C_UP,
outDirection = -1
),
CSTICK_DOWN(
SettingKeys.cstick_down(), Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_C_DOWN, outDirection = 1
),
CSTICK_LEFT(
SettingKeys.cstick_left(), Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_C_LEFT, outDirection = -1
),
CSTICK_RIGHT(
SettingKeys.cstick_right(), Settings.SECTION_CONTROLS,
outAxis = NativeLibrary.ButtonType.STICK_C_RIGHT, outDirection = 1
),
HOTKEY_CYCLE_LAYOUT(
SettingKeys.hotkey_cycle_layout(), Settings.SECTION_CONTROLS,
outKey = Hotkey.CYCLE_LAYOUT.button
),
HOTKEY_CLOSE_GAME(
SettingKeys.hotkey_close(), Settings.SECTION_CONTROLS,
outKey = Hotkey.CLOSE_GAME.button
),
HOTKEY_SWAP(
SettingKeys.hotkey_swap(), Settings.SECTION_CONTROLS,
outKey = Hotkey.SWAP_SCREEN.button
),
HOTKEY_PAUSE_OR_RESUME(
SettingKeys.hotkey_pause_resume(), Settings.SECTION_CONTROLS,
outKey = Hotkey.PAUSE_OR_RESUME.button
),
HOTKEY_QUICKSAVE(
SettingKeys.hotkey_quicksave(), Settings.SECTION_CONTROLS,
outKey = Hotkey.QUICKSAVE.button
),
HOTKEY_TURBO_LIMIT(
SettingKeys.hotkey_turbo_limit(), Settings.SECTION_CONTROLS,
outKey = Hotkey.TURBO_LIMIT.button
),
HOTKEY_QUICKLOAD(
SettingKeys.hotkey_quickload(), Settings.SECTION_CONTROLS,
outKey = Hotkey.QUICKLOAD.button
),
HOTKEY_ENABLE(
SettingKeys.hotkey_enable(), Settings.SECTION_CONTROLS,
outKey = Hotkey.ENABLE.button
);
/** Parse a configuration string into an input binding */
override fun valueFromString(string: String): Input {
if (string.isBlank()) return defaultValue
val params = string.split(",")
.mapNotNull { part ->
val split = part.split(":", limit = 2)
if (split.size < 2) null else split[0] to split[1]
}.toMap()
if (params["engine"] != "gamepad") return defaultValue
return Input(
key = params["code"]?.toIntOrNull(),
axis = params["axis"]?.toIntOrNull(),
direction = params["direction"]?.toIntOrNull(),
threshold = params["threshold"]?.toFloatOrNull(),
).takeIf { it.key != null || it.axis != null } ?: defaultValue
}
/** Create a configuration string from an input binding */
override fun valueToString(binding: Input?): String {
binding ?: return ""
if (binding.empty) return ""
val ret = "engine:gamepad"
return ret + when {
binding.key != null -> ",code:${binding.key}"
binding.axis != null -> buildString {
append(",axis:${binding.axis}")
binding.threshold?.let { append(",threshold:$it") }
binding.direction?.let { append(",direction:$it") }
}
else -> ""
}
}
/** What will display is different from what is saved in this case */
fun displayValue(binding: Input?): String {
if (binding?.key != null) {
return GamepadHelper.getButtonName(binding.key)
} else if (binding?.axis != null) {
return GamepadHelper.getAxisName(binding.axis, binding.direction)
} else {
return ""
}
}
override val isRuntimeEditable: Boolean = true
companion object {
fun from(key: String): InputMappingSetting? = values().firstOrNull { it.key == key }
}
}

View File

@ -4,12 +4,16 @@
package org.citra.citra_emu.features.settings.model
import org.citra.citra_emu.R
import org.citra.citra_emu.features.input.Input
import org.citra.citra_emu.features.input.InputMappingManager
import org.citra.citra_emu.features.settings.utils.SettingsFile
class Settings {
private val globalValues = HashMap<String, Any>()
private val perGameOverrides = HashMap<String, Any>()
val inputMappingManager = InputMappingManager()
var gameId: String? = null
fun isPerGame(): Boolean = gameId != null && gameId != ""
@ -26,24 +30,33 @@ class Settings {
return (globalValues[setting.key] ?: setting.defaultValue) as T
}
/** Sets the global value specifically */
fun <T> setGlobal(setting: AbstractSetting<T>, value: T) {
globalValues[setting.key] = value as Any
// only update the InputMapping if this global setting actually is in effect now
if (setting is InputMappingSetting && !hasOverride(setting)) {
inputMappingManager.rebind(setting, value as? Input)
}
}
/** Sets the override specifically */
fun <T> setOverride(setting: AbstractSetting<T>, value: T) {
perGameOverrides[setting.key] = value as Any
if (setting is InputMappingSetting) {
inputMappingManager.rebind(setting, value as? Input)
}
}
/** Sets the per-game or global setting based on whether this file has ANY per-game setting.
* This should be used, for example, by the Settings Activity
* This should be used by the Custom Settings Activity
*/
fun <T> set(setting: AbstractSetting<T>, value: T) {
if (isPerGame()) setOverride(setting, value) else setGlobal(setting, value)
}
/**
* Updates an existing setting honoring whether it is *currently* global or local. This will
* be used by the Quick Menu
* Updates an existing setting honoring whether this particular setting is *currently* global or local.
* This should be used by the Quick Menu
*/
fun <T> update(setting: AbstractSetting<T>, value: T) {
if (hasOverride(setting)) setOverride(setting, value) else setGlobal(setting, value)
@ -61,10 +74,15 @@ class Settings {
other.perGameOverrides.forEach{ (key, value) ->
perGameOverrides[key] = value
}
inputMappingManager.rebuild(this)
}
fun <T> clearOverride(setting: AbstractSetting<T>) {
perGameOverrides.remove(setting.key)
if (setting is InputMappingSetting) {
inputMappingManager.rebind(setting, getGlobal(setting))
}
}
fun hasOverride(setting: AbstractSetting<*>): Boolean {
@ -78,10 +96,12 @@ class Settings {
fun clearAll() {
globalValues.clear()
perGameOverrides.clear()
inputMappingManager.clear()
}
fun clearOverrides() {
perGameOverrides.clear()
inputMappingManager.rebuild(this)
}
fun removePerGameSettings() {
@ -91,7 +111,6 @@ class Settings {
companion object {
const val SECTION_CORE = "Core"
const val SECTION_SYSTEM = "System"
const val SECTION_CAMERA = "Camera"
@ -108,115 +127,6 @@ class Settings {
const val SECTION_STORAGE = "Storage"
const val SECTION_MISC = "Miscellaneous"
const val KEY_BUTTON_A = "button_a"
const val KEY_BUTTON_B = "button_b"
const val KEY_BUTTON_X = "button_x"
const val KEY_BUTTON_Y = "button_y"
const val KEY_BUTTON_SELECT = "button_select"
const val KEY_BUTTON_START = "button_start"
const val KEY_BUTTON_HOME = "button_home"
const val KEY_BUTTON_UP = "button_up"
const val KEY_BUTTON_DOWN = "button_down"
const val KEY_BUTTON_LEFT = "button_left"
const val KEY_BUTTON_RIGHT = "button_right"
const val KEY_BUTTON_L = "button_l"
const val KEY_BUTTON_R = "button_r"
const val KEY_BUTTON_ZL = "button_zl"
const val KEY_BUTTON_ZR = "button_zr"
const val KEY_CIRCLEPAD_AXIS_VERTICAL = "circlepad_axis_vertical"
const val KEY_CIRCLEPAD_AXIS_HORIZONTAL = "circlepad_axis_horizontal"
const val KEY_CSTICK_AXIS_VERTICAL = "cstick_axis_vertical"
const val KEY_CSTICK_AXIS_HORIZONTAL = "cstick_axis_horizontal"
const val KEY_DPAD_AXIS_VERTICAL = "dpad_axis_vertical"
const val KEY_DPAD_AXIS_HORIZONTAL = "dpad_axis_horizontal"
const val HOTKEY_ENABLE = "hotkey_enable"
const val HOTKEY_SCREEN_SWAP = "hotkey_screen_swap"
const val HOTKEY_CYCLE_LAYOUT = "hotkey_toggle_layout"
const val HOTKEY_CLOSE_GAME = "hotkey_close_game"
const val HOTKEY_PAUSE_OR_RESUME = "hotkey_pause_or_resume_game"
const val HOTKEY_QUICKSAVE = "hotkey_quickload"
const val HOTKEY_QUICKlOAD = "hotkey_quickpause"
const val HOTKEY_TURBO_LIMIT = "hotkey_turbo_limit"
val buttonKeys = listOf(
KEY_BUTTON_A,
KEY_BUTTON_B,
KEY_BUTTON_X,
KEY_BUTTON_Y,
KEY_BUTTON_SELECT,
KEY_BUTTON_START,
KEY_BUTTON_HOME
)
val buttonTitles = listOf(
R.string.button_a,
R.string.button_b,
R.string.button_x,
R.string.button_y,
R.string.button_select,
R.string.button_start,
R.string.button_home
)
val circlePadKeys = listOf(
KEY_CIRCLEPAD_AXIS_VERTICAL,
KEY_CIRCLEPAD_AXIS_HORIZONTAL
)
val cStickKeys = listOf(
KEY_CSTICK_AXIS_VERTICAL,
KEY_CSTICK_AXIS_HORIZONTAL
)
val dPadAxisKeys = listOf(
KEY_DPAD_AXIS_VERTICAL,
KEY_DPAD_AXIS_HORIZONTAL
)
val dPadButtonKeys = listOf(
KEY_BUTTON_UP,
KEY_BUTTON_DOWN,
KEY_BUTTON_LEFT,
KEY_BUTTON_RIGHT
)
val axisTitles = listOf(
R.string.controller_axis_vertical,
R.string.controller_axis_horizontal
)
val dPadTitles = listOf(
R.string.direction_up,
R.string.direction_down,
R.string.direction_left,
R.string.direction_right
)
val triggerKeys = listOf(
KEY_BUTTON_L,
KEY_BUTTON_R,
KEY_BUTTON_ZL,
KEY_BUTTON_ZR
)
val triggerTitles = listOf(
R.string.button_l,
R.string.button_r,
R.string.button_zl,
R.string.button_zr
)
val hotKeys = listOf(
HOTKEY_ENABLE,
HOTKEY_SCREEN_SWAP,
HOTKEY_CYCLE_LAYOUT,
HOTKEY_CLOSE_GAME,
HOTKEY_PAUSE_OR_RESUME,
HOTKEY_QUICKSAVE,
HOTKEY_QUICKlOAD,
HOTKEY_TURBO_LIMIT
)
val hotkeyTitles = listOf(
R.string.controller_hotkey_enable_button,
R.string.emulation_swap_screens,
R.string.emulation_cycle_landscape_layouts,
R.string.emulation_close_game,
R.string.emulation_toggle_pause,
R.string.emulation_quicksave,
R.string.emulation_quickload,
R.string.turbo_limit_hotkey
)
// TODO: Move these in with the other setting keys in GenerateSettingKeys.cmake
const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch"
const val PREF_MATERIAL_YOU = "MaterialYouTheme"

View File

@ -4,593 +4,58 @@
package org.citra.citra_emu.features.settings.model.view
import android.content.Context
import android.content.SharedPreferences
import android.view.InputDevice
import android.view.InputDevice.MotionRange
import android.view.KeyEvent
import android.view.MotionEvent
import android.widget.Toast
import androidx.preference.PreferenceManager
import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.R
import org.citra.citra_emu.features.hotkeys.Hotkey
import org.citra.citra_emu.features.settings.model.AbstractSetting
import org.citra.citra_emu.features.input.Input
import org.citra.citra_emu.features.input.GamepadHelper
import org.citra.citra_emu.features.settings.model.InputMappingSetting
import org.citra.citra_emu.features.settings.model.Settings
class InputBindingSetting(
val abstractSetting: AbstractSetting<*>,
val inputSetting: InputMappingSetting,
val settings: Settings,
titleId: Int
) : SettingsItem(abstractSetting, titleId, 0) {
private val context: Context get() = CitraApplication.appContext
private val preferences: SharedPreferences
get() = PreferenceManager.getDefaultSharedPreferences(context)
var value: String
get() = preferences.getString(abstractSetting.key, "")!!
set(string) {
preferences.edit()
.putString(abstractSetting.key, string)
.apply()
}
/**
* Returns true if this key is for the 3DS Circle Pad
*/
fun isCirclePad(): Boolean =
when (abstractSetting.key) {
Settings.KEY_CIRCLEPAD_AXIS_HORIZONTAL,
Settings.KEY_CIRCLEPAD_AXIS_VERTICAL -> true
else -> false
}
/**
* Returns true if this key is for a horizontal axis for a 3DS analog stick or D-pad
*/
fun isHorizontalOrientation(): Boolean =
when (abstractSetting.key) {
Settings.KEY_CIRCLEPAD_AXIS_HORIZONTAL,
Settings.KEY_CSTICK_AXIS_HORIZONTAL,
Settings.KEY_DPAD_AXIS_HORIZONTAL -> true
else -> false
}
/**
* Returns true if this key is for the 3DS C-Stick
*/
fun isCStick(): Boolean =
when (abstractSetting.key) {
Settings.KEY_CSTICK_AXIS_HORIZONTAL,
Settings.KEY_CSTICK_AXIS_VERTICAL -> true
else -> false
}
/**
* Returns true if this key is for the 3DS D-Pad
*/
fun isDPad(): Boolean =
when (abstractSetting.key) {
Settings.KEY_DPAD_AXIS_HORIZONTAL,
Settings.KEY_DPAD_AXIS_VERTICAL -> true
else -> false
}
/**
* Returns true if this key is for the 3DS L/R or ZL/ZR buttons. Note, these are not real
* triggers on the 3DS, but we support them as such on a physical gamepad.
*/
fun isTrigger(): Boolean =
when (abstractSetting.key) {
Settings.KEY_BUTTON_L,
Settings.KEY_BUTTON_R,
Settings.KEY_BUTTON_ZL,
Settings.KEY_BUTTON_ZR -> true
else -> false
}
/**
* Returns true if a gamepad axis can be used to map this key.
*/
fun isAxisMappingSupported(): Boolean {
return isCirclePad() || isCStick() || isDPad() || isTrigger()
}
/**
* Returns true if a gamepad button can be used to map this key.
*/
fun isButtonMappingSupported(): Boolean {
return !isAxisMappingSupported() || isTrigger()
}
/**
* Returns the Citra button code for the settings key.
*/
private val buttonCode: Int
get() =
when (abstractSetting.key) {
Settings.KEY_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_A
Settings.KEY_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_B
Settings.KEY_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X
Settings.KEY_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y
Settings.KEY_BUTTON_L -> NativeLibrary.ButtonType.TRIGGER_L
Settings.KEY_BUTTON_R -> NativeLibrary.ButtonType.TRIGGER_R
Settings.KEY_BUTTON_ZL -> NativeLibrary.ButtonType.BUTTON_ZL
Settings.KEY_BUTTON_ZR -> NativeLibrary.ButtonType.BUTTON_ZR
Settings.KEY_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_SELECT
Settings.KEY_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_START
Settings.KEY_BUTTON_HOME -> NativeLibrary.ButtonType.BUTTON_HOME
Settings.KEY_BUTTON_UP -> NativeLibrary.ButtonType.DPAD_UP
Settings.KEY_BUTTON_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN
Settings.KEY_BUTTON_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT
Settings.KEY_BUTTON_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT
Settings.HOTKEY_ENABLE -> Hotkey.ENABLE.button
Settings.HOTKEY_SCREEN_SWAP -> Hotkey.SWAP_SCREEN.button
Settings.HOTKEY_CYCLE_LAYOUT -> Hotkey.CYCLE_LAYOUT.button
Settings.HOTKEY_CLOSE_GAME -> Hotkey.CLOSE_GAME.button
Settings.HOTKEY_PAUSE_OR_RESUME -> Hotkey.PAUSE_OR_RESUME.button
Settings.HOTKEY_QUICKSAVE -> Hotkey.QUICKSAVE.button
Settings.HOTKEY_QUICKlOAD -> Hotkey.QUICKLOAD.button
Settings.HOTKEY_TURBO_LIMIT -> Hotkey.TURBO_LIMIT.button
else -> -1
}
/**
* Returns the key used to lookup the reverse mapping for this key, which is used to cleanup old
* settings on re-mapping or clearing of a setting.
*/
private val reverseKey: String
) : SettingsItem(inputSetting, titleId, 0) {
val value: String
get() {
var reverseKey = "${INPUT_MAPPING_PREFIX}_ReverseMapping_${abstractSetting.key}"
if (isAxisMappingSupported() && !isTrigger()) {
// Triggers are the only axis-supported mappings without orientation
reverseKey += "_" + if (isHorizontalOrientation()) {
0
} else {
1
}
}
return reverseKey
val mapping = settings.get(inputSetting) ?: return ""
return inputSetting.displayValue(mapping)
}
/**
* Removes the old mapping for this key from the settings, e.g. on user clearing the setting.
*/
@Suppress("UNCHECKED_CAST")
fun removeOldMapping() {
// Try remove all possible keys we wrote for this setting
val oldKey = preferences.getString(reverseKey, "")
if (oldKey != "") {
//settings.set(setting as AbstractSetting<String>,"")
preferences.edit()
.remove(abstractSetting.key) // Used for ui text
.remove(oldKey + "_GuestOrientation") // Used for axis orientation
.remove(oldKey + "_GuestButton") // Used for axis button
.remove(oldKey + "_Inverted") // used for axis inversion
.remove(reverseKey)
val buttonCodes = try {
preferences.getStringSet(oldKey, mutableSetOf<String>())!!.toMutableSet()
} catch (e: ClassCastException) {
// if this is an int pref, either old button or an axis, so just remove it
preferences.edit().remove(oldKey).apply()
return;
}
buttonCodes.remove(buttonCode.toString());
preferences.edit().putStringSet(oldKey,buttonCodes).apply()
}
/** Default value is cleared */
settings.set(inputSetting, inputSetting.defaultValue)
}
/**
* Helper function to write a gamepad button mapping for the setting.
*/
private fun writeButtonMapping(keyEvent: KeyEvent) {
val editor = preferences.edit()
val key = getInputButtonKey(keyEvent)
// Pull in all codes associated with this key
// Migrate from the old int preference if need be
val buttonCodes = InputBindingSetting.getButtonSet(keyEvent)
buttonCodes.add(buttonCode)
// Cleanup old mapping for this setting
removeOldMapping()
editor.putStringSet(key, buttonCodes.mapTo(mutableSetOf()) {it.toString()})
// Write next reverse mapping for future cleanup
editor.putString(reverseKey, key)
// Apply changes
editor.apply()
}
/**
* Helper function to write a gamepad axis mapping for the setting.
*/
private fun writeAxisMapping(axis: Int, value: Int, inverted: Boolean) {
// Cleanup old mapping
removeOldMapping()
// Write new mapping
preferences.edit()
.putInt(getInputAxisOrientationKey(axis), if (isHorizontalOrientation()) 0 else 1)
.putInt(getInputAxisButtonKey(axis), value)
.putBoolean(getInputAxisInvertedKey(axis),inverted)
// Write next reverse mapping for future cleanup
.putString(reverseKey, getInputAxisKey(axis))
.apply()
}
/**
* Saves the provided key input setting as an Android preference.
* Saves the provided key input to the setting
*
* @param keyEvent KeyEvent of this key press.
*/
fun onKeyInput(keyEvent: KeyEvent) {
if (!isButtonMappingSupported()) {
Toast.makeText(context, R.string.input_message_analog_only, Toast.LENGTH_LONG).show()
return
}
val code = translateEventToKeyId(keyEvent)
writeButtonMapping(keyEvent)
value = "${keyEvent.device.name}: ${getButtonName(code)}"
val mapping = Input(key = code)
settings.set(inputSetting, mapping)
}
/**
* Saves the provided motion input setting as an Android preference.
*
* @param device InputDevice from which the input event originated.
* @param motionRange MotionRange of the movement
* @param axisDir Either '-' or '+'
* @param axisDir Either -1 or 1
*/
fun onMotionInput(device: InputDevice, motionRange: MotionRange, axisDir: Char) {
if (!isAxisMappingSupported()) {
Toast.makeText(context, R.string.input_message_button_only, Toast.LENGTH_LONG).show()
return
}
val button = if (isCirclePad()) {
NativeLibrary.ButtonType.STICK_LEFT
} else if (isCStick()) {
NativeLibrary.ButtonType.STICK_C
} else if (isDPad()) {
NativeLibrary.ButtonType.DPAD
fun onMotionInput(motionRange: MotionRange, axisDir: Int) {
val mapping = Input(axis = motionRange.axis, direction = axisDir, threshold = 0.5f)
settings.set(inputSetting, mapping)
}
private fun translateEventToKeyId(event: KeyEvent): Int {
return if (event.keyCode == 0) {
event.scanCode
} else {
buttonCode
event.keyCode
}
// use UP (-) to map vertical, but use RIGHT (+) to map horizontal
val inverted = if (isHorizontalOrientation()) axisDir == '-' else axisDir == '+'
writeAxisMapping(motionRange.axis, button, inverted)
value = "Axis ${motionRange.axis}$axisDir"
}
override val type = TYPE_INPUT_BINDING
companion object {
private const val INPUT_MAPPING_PREFIX = "InputMapping"
private fun toTitleCase(raw: String): String =
raw.replace("_", " ").lowercase()
.split(" ").joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }
private const val BUTTON_NAME_L3 = "Button L3"
private const val BUTTON_NAME_R3 = "Button R3"
private val buttonNameOverrides = mapOf(
KeyEvent.KEYCODE_BUTTON_THUMBL to BUTTON_NAME_L3,
KeyEvent.KEYCODE_BUTTON_THUMBR to BUTTON_NAME_R3,
LINUX_BTN_DPAD_UP to "Dpad Up",
LINUX_BTN_DPAD_DOWN to "Dpad Down",
LINUX_BTN_DPAD_LEFT to "Dpad Left",
LINUX_BTN_DPAD_RIGHT to "Dpad Right"
)
fun getButtonName(keyCode: Int): String =
buttonNameOverrides[keyCode]
?: toTitleCase(KeyEvent.keyCodeToString(keyCode).removePrefix("KEYCODE_"))
private data class DefaultButtonMapping(
val settingKey: String,
val hostKeyCode: Int,
val guestButtonCode: Int
)
// Auto-map always sets inverted = false. Users needing inverted axes should remap manually.
private data class DefaultAxisMapping(
val settingKey: String,
val hostAxis: Int,
val guestButton: Int,
val orientation: Int,
val inverted: Boolean
)
private val xboxFaceButtonMappings = listOf(
DefaultButtonMapping(Settings.KEY_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B, NativeLibrary.ButtonType.BUTTON_A),
DefaultButtonMapping(Settings.KEY_BUTTON_B, KeyEvent.KEYCODE_BUTTON_A, NativeLibrary.ButtonType.BUTTON_B),
DefaultButtonMapping(Settings.KEY_BUTTON_X, KeyEvent.KEYCODE_BUTTON_Y, NativeLibrary.ButtonType.BUTTON_X),
DefaultButtonMapping(Settings.KEY_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_X, NativeLibrary.ButtonType.BUTTON_Y)
)
private val nintendoFaceButtonMappings = listOf(
DefaultButtonMapping(Settings.KEY_BUTTON_A, KeyEvent.KEYCODE_BUTTON_A, NativeLibrary.ButtonType.BUTTON_A),
DefaultButtonMapping(Settings.KEY_BUTTON_B, KeyEvent.KEYCODE_BUTTON_B, NativeLibrary.ButtonType.BUTTON_B),
DefaultButtonMapping(Settings.KEY_BUTTON_X, KeyEvent.KEYCODE_BUTTON_X, NativeLibrary.ButtonType.BUTTON_X),
DefaultButtonMapping(Settings.KEY_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_Y, NativeLibrary.ButtonType.BUTTON_Y)
)
private val commonButtonMappings = listOf(
DefaultButtonMapping(Settings.KEY_BUTTON_L, KeyEvent.KEYCODE_BUTTON_L1, NativeLibrary.ButtonType.TRIGGER_L),
DefaultButtonMapping(Settings.KEY_BUTTON_R, KeyEvent.KEYCODE_BUTTON_R1, NativeLibrary.ButtonType.TRIGGER_R),
DefaultButtonMapping(Settings.KEY_BUTTON_ZL, KeyEvent.KEYCODE_BUTTON_L2, NativeLibrary.ButtonType.BUTTON_ZL),
DefaultButtonMapping(Settings.KEY_BUTTON_ZR, KeyEvent.KEYCODE_BUTTON_R2, NativeLibrary.ButtonType.BUTTON_ZR),
DefaultButtonMapping(Settings.KEY_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_SELECT, NativeLibrary.ButtonType.BUTTON_SELECT),
DefaultButtonMapping(Settings.KEY_BUTTON_START, KeyEvent.KEYCODE_BUTTON_START, NativeLibrary.ButtonType.BUTTON_START)
)
private val dpadButtonMappings = listOf(
DefaultButtonMapping(Settings.KEY_BUTTON_UP, KeyEvent.KEYCODE_DPAD_UP, NativeLibrary.ButtonType.DPAD_UP),
DefaultButtonMapping(Settings.KEY_BUTTON_DOWN, KeyEvent.KEYCODE_DPAD_DOWN, NativeLibrary.ButtonType.DPAD_DOWN),
DefaultButtonMapping(Settings.KEY_BUTTON_LEFT, KeyEvent.KEYCODE_DPAD_LEFT, NativeLibrary.ButtonType.DPAD_LEFT),
DefaultButtonMapping(Settings.KEY_BUTTON_RIGHT, KeyEvent.KEYCODE_DPAD_RIGHT, NativeLibrary.ButtonType.DPAD_RIGHT)
)
private val stickAxisMappings = listOf(
DefaultAxisMapping(Settings.KEY_CIRCLEPAD_AXIS_HORIZONTAL, MotionEvent.AXIS_X, NativeLibrary.ButtonType.STICK_LEFT, 0, false),
DefaultAxisMapping(Settings.KEY_CIRCLEPAD_AXIS_VERTICAL, MotionEvent.AXIS_Y, NativeLibrary.ButtonType.STICK_LEFT, 1, false),
DefaultAxisMapping(Settings.KEY_CSTICK_AXIS_HORIZONTAL, MotionEvent.AXIS_Z, NativeLibrary.ButtonType.STICK_C, 0, false),
DefaultAxisMapping(Settings.KEY_CSTICK_AXIS_VERTICAL, MotionEvent.AXIS_RZ, NativeLibrary.ButtonType.STICK_C, 1, false)
)
private val dpadAxisMappings = listOf(
DefaultAxisMapping(Settings.KEY_DPAD_AXIS_HORIZONTAL, MotionEvent.AXIS_HAT_X, NativeLibrary.ButtonType.DPAD, 0, false),
DefaultAxisMapping(Settings.KEY_DPAD_AXIS_VERTICAL, MotionEvent.AXIS_HAT_Y, NativeLibrary.ButtonType.DPAD, 1, false)
)
// Nintendo Switch Joy-Con specific mappings.
// Joy-Cons connected via Bluetooth on Android have several quirks:
// - They register as two separate InputDevices (left and right)
// - Android's evdev translation swaps A<->B (BTN_EAST->BUTTON_B, BTN_SOUTH->BUTTON_A)
// but does NOT swap X<->Y (BTN_NORTH->BUTTON_X, BTN_WEST->BUTTON_Y)
// - D-pad buttons arrive as KEYCODE_UNKNOWN (0) with Linux BTN_DPAD_* scan codes
// - Right stick uses AXIS_RX/AXIS_RY instead of AXIS_Z/AXIS_RZ
private const val NINTENDO_VENDOR_ID = 0x057e
// Linux BTN_DPAD_* values (0x220-0x223). Joy-Con D-pad buttons arrive as
// KEYCODE_UNKNOWN with these scan codes because Android's input layer doesn't
// translate them to KEYCODE_DPAD_*. translateEventToKeyId() falls back to
// the scan code in that case.
private const val LINUX_BTN_DPAD_UP = 0x220 // 544
private const val LINUX_BTN_DPAD_DOWN = 0x221 // 545
private const val LINUX_BTN_DPAD_LEFT = 0x222 // 546
private const val LINUX_BTN_DPAD_RIGHT = 0x223 // 547
// Joy-Con face buttons: A/B are swapped by Android's evdev layer, but X/Y are not.
// This is different from both the standard Xbox table (full swap) and the
// Nintendo table (no swap).
private val joyconFaceButtonMappings = listOf(
DefaultButtonMapping(Settings.KEY_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B, NativeLibrary.ButtonType.BUTTON_A),
DefaultButtonMapping(Settings.KEY_BUTTON_B, KeyEvent.KEYCODE_BUTTON_A, NativeLibrary.ButtonType.BUTTON_B),
DefaultButtonMapping(Settings.KEY_BUTTON_X, KeyEvent.KEYCODE_BUTTON_X, NativeLibrary.ButtonType.BUTTON_X),
DefaultButtonMapping(Settings.KEY_BUTTON_Y, KeyEvent.KEYCODE_BUTTON_Y, NativeLibrary.ButtonType.BUTTON_Y)
)
// Joy-Con D-pad: uses Linux scan codes because Android reports BTN_DPAD_* as KEYCODE_UNKNOWN
private val joyconDpadButtonMappings = listOf(
DefaultButtonMapping(Settings.KEY_BUTTON_UP, LINUX_BTN_DPAD_UP, NativeLibrary.ButtonType.DPAD_UP),
DefaultButtonMapping(Settings.KEY_BUTTON_DOWN, LINUX_BTN_DPAD_DOWN, NativeLibrary.ButtonType.DPAD_DOWN),
DefaultButtonMapping(Settings.KEY_BUTTON_LEFT, LINUX_BTN_DPAD_LEFT, NativeLibrary.ButtonType.DPAD_LEFT),
DefaultButtonMapping(Settings.KEY_BUTTON_RIGHT, LINUX_BTN_DPAD_RIGHT, NativeLibrary.ButtonType.DPAD_RIGHT)
)
// Joy-Con sticks: left stick is AXIS_X/Y (standard), right stick is AXIS_RX/RY
// (not Z/RZ like most controllers). The horizontal axis is inverted relative to
// the standard orientation - verified empirically on paired Joy-Cons via Bluetooth.
private val joyconStickAxisMappings = listOf(
DefaultAxisMapping(Settings.KEY_CIRCLEPAD_AXIS_HORIZONTAL, MotionEvent.AXIS_X, NativeLibrary.ButtonType.STICK_LEFT, 0, false),
DefaultAxisMapping(Settings.KEY_CIRCLEPAD_AXIS_VERTICAL, MotionEvent.AXIS_Y, NativeLibrary.ButtonType.STICK_LEFT, 1, false),
DefaultAxisMapping(Settings.KEY_CSTICK_AXIS_HORIZONTAL, MotionEvent.AXIS_RX, NativeLibrary.ButtonType.STICK_C, 0, true),
DefaultAxisMapping(Settings.KEY_CSTICK_AXIS_VERTICAL, MotionEvent.AXIS_RY, NativeLibrary.ButtonType.STICK_C, 1, false)
)
/**
* Detects whether a device is a Nintendo Switch Joy-Con (as opposed to a
* Pro Controller or other Nintendo device) by checking vendor ID + device
* capabilities. Joy-Cons lack AXIS_HAT_X/Y and use AXIS_RX/RY for the
* right stick, while the Pro Controller has standard HAT axes and Z/RZ.
*/
fun isJoyCon(device: InputDevice?): Boolean {
if (device == null) return false
if (device.vendorId != NINTENDO_VENDOR_ID) return false
// Pro Controllers have HAT_X/HAT_Y (D-pad) and Z/RZ (right stick).
// Joy-Cons lack both: no HAT axes, right stick on RX/RY instead of Z/RZ.
var hasHatAxes = false
var hasStandardRightStick = false
for (range in device.motionRanges) {
when (range.axis) {
MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y -> hasHatAxes = true
MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ -> hasStandardRightStick = true
}
}
return !hasHatAxes && !hasStandardRightStick
}
private val allBindingKeys: Set<String> by lazy {
(Settings.buttonKeys + Settings.triggerKeys +
Settings.circlePadKeys + Settings.cStickKeys + Settings.dPadAxisKeys +
Settings.dPadButtonKeys).toSet()
}
fun clearAllBindings() {
val prefs = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
val editor = prefs.edit()
val allKeys = prefs.all.keys.toList()
for (key in allKeys) {
if (key.startsWith(INPUT_MAPPING_PREFIX) || key in allBindingKeys) {
editor.remove(key)
}
}
editor.apply()
}
private fun applyBindings(
buttonMappings: List<DefaultButtonMapping>,
axisMappings: List<DefaultAxisMapping>
) {
val prefs = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
val editor = prefs.edit()
buttonMappings.forEach { applyDefaultButtonMapping(editor, it) }
axisMappings.forEach { applyDefaultAxisMapping(editor, it) }
editor.apply()
}
/**
* Applies Joy-Con specific bindings: scan code D-pad, partial face button
* swap, and AXIS_RX/RY right stick.
*/
fun applyJoyConBindings() {
applyBindings(
joyconFaceButtonMappings + commonButtonMappings + joyconDpadButtonMappings,
joyconStickAxisMappings
)
}
/**
* Applies auto-mapped bindings based on detected controller layout and d-pad type.
*
* @param isNintendoLayout true if the controller uses Nintendo face button layout
* (A=east, B=south), false for Xbox layout (A=south, B=east)
* @param useAxisDpad true if the d-pad should be mapped as axis (HAT_X/HAT_Y),
* false if it should be mapped as individual button keycodes (DPAD_UP/DOWN/LEFT/RIGHT)
*/
fun applyAutoMapBindings(isNintendoLayout: Boolean, useAxisDpad: Boolean) {
val faceButtons = if (isNintendoLayout) nintendoFaceButtonMappings else xboxFaceButtonMappings
val buttonMappings = if (useAxisDpad) {
faceButtons + commonButtonMappings
} else {
faceButtons + commonButtonMappings + dpadButtonMappings
}
val axisMappings = if (useAxisDpad) {
stickAxisMappings + dpadAxisMappings
} else {
stickAxisMappings
}
applyBindings(buttonMappings, axisMappings)
}
private fun applyDefaultButtonMapping(
editor: SharedPreferences.Editor,
mapping: DefaultButtonMapping
) {
val prefKey = getInputButtonKey(mapping.hostKeyCode)
editor.putInt(prefKey, mapping.guestButtonCode)
editor.putString(mapping.settingKey, getButtonName(mapping.hostKeyCode))
editor.putString(
"${INPUT_MAPPING_PREFIX}_ReverseMapping_${mapping.settingKey}",
prefKey
)
}
private fun applyDefaultAxisMapping(
editor: SharedPreferences.Editor,
mapping: DefaultAxisMapping
) {
val axisKey = getInputAxisKey(mapping.hostAxis)
editor.putInt(getInputAxisOrientationKey(mapping.hostAxis), mapping.orientation)
editor.putInt(getInputAxisButtonKey(mapping.hostAxis), mapping.guestButton)
editor.putBoolean(getInputAxisInvertedKey(mapping.hostAxis), mapping.inverted)
val dir = if (mapping.orientation == 0) '+' else '-'
editor.putString(mapping.settingKey, "Axis ${mapping.hostAxis}$dir")
val reverseKey = "${INPUT_MAPPING_PREFIX}_ReverseMapping_${mapping.settingKey}_${mapping.orientation}"
editor.putString(reverseKey, axisKey)
}
/**
* Returns the settings key for the specified Citra button code.
*/
private fun getButtonKey(buttonCode: Int): String =
when (buttonCode) {
NativeLibrary.ButtonType.BUTTON_A -> Settings.KEY_BUTTON_A
NativeLibrary.ButtonType.BUTTON_B -> Settings.KEY_BUTTON_B
NativeLibrary.ButtonType.BUTTON_X -> Settings.KEY_BUTTON_X
NativeLibrary.ButtonType.BUTTON_Y -> Settings.KEY_BUTTON_Y
NativeLibrary.ButtonType.TRIGGER_L -> Settings.KEY_BUTTON_L
NativeLibrary.ButtonType.TRIGGER_R -> Settings.KEY_BUTTON_R
NativeLibrary.ButtonType.BUTTON_ZL -> Settings.KEY_BUTTON_ZL
NativeLibrary.ButtonType.BUTTON_ZR -> Settings.KEY_BUTTON_ZR
NativeLibrary.ButtonType.BUTTON_SELECT -> Settings.KEY_BUTTON_SELECT
NativeLibrary.ButtonType.BUTTON_START -> Settings.KEY_BUTTON_START
NativeLibrary.ButtonType.BUTTON_HOME -> Settings.KEY_BUTTON_HOME
NativeLibrary.ButtonType.DPAD_UP -> Settings.KEY_BUTTON_UP
NativeLibrary.ButtonType.DPAD_DOWN -> Settings.KEY_BUTTON_DOWN
NativeLibrary.ButtonType.DPAD_LEFT -> Settings.KEY_BUTTON_LEFT
NativeLibrary.ButtonType.DPAD_RIGHT -> Settings.KEY_BUTTON_RIGHT
else -> ""
}
/**
* Get the mutable set of int button values this key should map to given an event
*/
fun getButtonSet(keyCode: KeyEvent):MutableSet<Int> {
val key = getInputButtonKey(keyCode)
val preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
var buttonCodes = try {
preferences.getStringSet(key, mutableSetOf<String>())
} catch (e: ClassCastException) {
val prefInt = preferences.getInt(key, -1);
val migratedSet = if (prefInt != -1) {
mutableSetOf(prefInt.toString())
} else {
mutableSetOf<String>()
}
migratedSet
}
if (buttonCodes == null) buttonCodes = mutableSetOf<String>()
return buttonCodes.mapNotNull { it.toIntOrNull() }.toMutableSet()
}
private fun getInputButtonKey(keyId: Int): String = "${INPUT_MAPPING_PREFIX}_HostAxis_${keyId}"
/** Falls back to the scan code when keyCode is KEYCODE_UNKNOWN. */
fun getInputButtonKey(event: KeyEvent): String = getInputButtonKey(translateEventToKeyId(event))
/**
* Helper function to get the settings key for an gamepad axis.
*/
fun getInputAxisKey(axis: Int): String = "${INPUT_MAPPING_PREFIX}_HostAxis_${axis}"
/**
* Helper function to get the settings key for an gamepad axis button (stick or trigger).
*/
fun getInputAxisButtonKey(axis: Int): String = "${getInputAxisKey(axis)}_GuestButton"
/**
* Helper function to get the settings key for an whether a gamepad axis is inverted.
*/
fun getInputAxisInvertedKey(axis: Int): String = "${getInputAxisKey(axis)}_Inverted"
/**
* Helper function to get the settings key for an gamepad axis orientation.
*/
fun getInputAxisOrientationKey(axis: Int): String =
"${getInputAxisKey(axis)}_GuestOrientation"
/**
* This function translates a keyEvent into an "keyid"
* This key id is either the keyCode from the event, or
* the raw scanCode.
* Only when the keyCode itself is 0, (so it is an unknown key)
* we fall back to the raw scan code.
* This handles keys like the media-keys on google statia-controllers
* that don't have a conventional "mapping" and report as "unknown"
*/
fun translateEventToKeyId(event: KeyEvent): Int {
return if (event.keyCode == 0) {
event.scanCode
} else {
event.keyCode
}
}
}
}

View File

@ -19,7 +19,6 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.preference.PreferenceManager
import com.google.android.material.color.MaterialColors
import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.NativeLibrary
@ -199,13 +198,6 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView {
// Prevents saving to a non-existent settings file
presenter.onSettingsReset()
val controllerKeys = Settings.buttonKeys + Settings.circlePadKeys + Settings.cStickKeys +
Settings.dPadAxisKeys + Settings.dPadButtonKeys + Settings.triggerKeys
val editor =
PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext).edit()
controllerKeys.forEach { editor.remove(it) }
editor.apply()
// Delete settings file because the user may have changed values that do not exist in the UI
val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG)
if (!settingsFile.delete()) {

View File

@ -33,7 +33,7 @@ class SettingsActivityPresenter(private val activityView: SettingsActivityView,
this.menuTag = menuTag
this.gameId = gameId
perGameInGlobalContext = gameId != "" && !Settings.settings.isPerGame()
perGameInGlobalContext = gameId != "" && ! Settings.settings.isPerGame()
// sync the active settings into my local settings appropriately
// if we are editing global settings rom a game, this should just work

View File

@ -248,7 +248,7 @@ class SettingsAdapter(
private fun onMultiChoiceClick(item: MultiChoiceSetting) {
clickedItem = item
val value: BooleanArray = getSelectionForMultiChoiceValue(item);
val value: BooleanArray = getSelectionForMultiChoiceValue(item)
dialog = MaterialAlertDialogBuilder(context)
.setTitle(item.nameId)
.setMultiChoiceItems(item.choicesId, value, this)
@ -268,7 +268,7 @@ class SettingsAdapter(
private fun onStringSingleChoiceClick(item: StringSingleChoiceSetting) {
clickedItem = item
dialog = context?.let {
dialog = context.let {
MaterialAlertDialogBuilder(it)
.setTitle(item.nameId)
.setSingleChoiceItems(item.choices, item.selectValueIndex, this)
@ -370,9 +370,9 @@ class SettingsAdapter(
value = sliderProgress
textSliderValue?.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable) {
var textValue = s.toString().toFloatOrNull();
var textValue = s.toString().toFloatOrNull()
if (item.setting !is FloatSetting) {
textValue = textValue?.roundToInt()?.toFloat();
textValue = textValue?.roundToInt()?.toFloat()
}
if (textValue == null || textValue < valueFrom || textValue > valueTo) {
textInputLayout?.error = "Inappropriate value"
@ -469,7 +469,7 @@ class SettingsAdapter(
val scSetting = clickedItem as? SingleChoiceSetting
scSetting?.let {
val value = getValueForSingleChoiceSelection(it, which)
if (it.selectedValue != value) fragmentView?.onSettingChanged()
if (it.selectedValue != value) fragmentView.onSettingChanged()
it.setSelectedValue(value)
fragmentView.loadSettingsList()
closeDialog()
@ -480,7 +480,7 @@ class SettingsAdapter(
val scSetting = clickedItem as? StringSingleChoiceSetting
scSetting?.let {
val value = it.getValueAt(which) ?: ""
if (it.selectedValue != value) fragmentView?.onSettingChanged()
if (it.selectedValue != value) fragmentView.onSettingChanged()
it.setSelectedValue(value)
fragmentView.loadSettingsList()
closeDialog()
@ -492,7 +492,6 @@ class SettingsAdapter(
sliderSetting?.let {
val sliderval = it.roundedFloat(sliderProgress)
if (sliderval != it.selectedFloat) fragmentView.onSettingChanged()
val s = it.setting
when {
it.setting?.defaultValue is Int -> it.setSelectedValue(sliderProgress.roundToInt())
else -> it.setSelectedValue(sliderProgress)
@ -506,7 +505,7 @@ class SettingsAdapter(
val inputSetting = clickedItem as? StringInputSetting
inputSetting?.let {
if (it.selectedValue != textInputValue) {
fragmentView?.onSettingChanged()
fragmentView.onSettingChanged()
}
it.setSelectedValue(textInputValue)
fragmentView.loadSettingsList()
@ -602,8 +601,9 @@ class SettingsAdapter(
}
fun onLongClickAutoMap(): Boolean {
val settings = fragmentView.activityView?.settings ?: return false
showConfirmationDialog(R.string.controller_clear_all, R.string.controller_clear_all_confirm) {
InputBindingSetting.clearAllBindings()
settings.inputMappingManager.clear()
fragmentView.loadSettingsList()
fragmentView.onSettingChanged()
}
@ -682,18 +682,18 @@ class SettingsAdapter(
}
private fun getSelectionForMultiChoiceValue(item: MultiChoiceSetting): BooleanArray {
val value = item.selectedValues;
val valuesId = item.valuesId;
val value = item.selectedValues
val valuesId = item.valuesId
if (valuesId > 0) {
val valuesArray = context.resources.getIntArray(valuesId);
val valuesArray = context.resources.getIntArray(valuesId)
val res = BooleanArray(valuesArray.size){false}
for (index in valuesArray.indices) {
if (value.contains(valuesArray[index])) {
res[index] = true;
res[index] = true
}
}
return res;
return res
}
return BooleanArray(1){false};
return BooleanArray(1){false}
}
}

View File

@ -19,7 +19,6 @@ import org.citra.citra_emu.R
import org.citra.citra_emu.display.ScreenLayout
import org.citra.citra_emu.display.StereoMode
import org.citra.citra_emu.display.StereoWhichDisplay
import org.citra.citra_emu.features.settings.model.AbstractSetting
import org.citra.citra_emu.features.settings.model.BooleanSetting
import org.citra.citra_emu.features.settings.model.FloatSetting
import org.citra.citra_emu.features.settings.model.IntSetting
@ -45,6 +44,8 @@ import org.citra.citra_emu.utils.Log
import org.citra.citra_emu.utils.SystemSaveGame
import org.citra.citra_emu.utils.ThemeUtil
import kotlin.math.roundToInt
import org.citra.citra_emu.features.input.GamepadHelper
import org.citra.citra_emu.features.settings.model.InputMappingSetting
class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) {
private var menuTag: String? = null
@ -754,45 +755,36 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView)
)
)
add(HeaderSetting(R.string.generic_buttons))
Settings.buttonKeys.forEachIndexed { i: Int, key: String ->
val button = getInputObject(key)
add(InputBindingSetting(button, Settings.buttonTitles[i]))
GamepadHelper.buttonKeys.forEachIndexed { i: Int, setting: InputMappingSetting ->
add(InputBindingSetting(setting, settings, GamepadHelper.buttonTitles[i]))
}
add(HeaderSetting(R.string.controller_circlepad))
Settings.circlePadKeys.forEachIndexed { i: Int, key: String ->
val button = getInputObject(key)
add(InputBindingSetting(button, Settings.axisTitles[i]))
GamepadHelper.circlePadKeys.forEachIndexed { i: Int, setting: InputMappingSetting ->
add(InputBindingSetting(setting, settings,GamepadHelper.axisTitles[i]))
}
add(HeaderSetting(R.string.controller_c))
Settings.cStickKeys.forEachIndexed { i: Int, key: String ->
val button = getInputObject(key)
add(InputBindingSetting(button, Settings.axisTitles[i]))
GamepadHelper.cStickKeys.forEachIndexed { i: Int, setting: InputMappingSetting ->
add(InputBindingSetting(setting, settings, GamepadHelper.axisTitles[i]))
}
add(HeaderSetting(R.string.controller_dpad_axis,R.string.controller_dpad_axis_description))
Settings.dPadAxisKeys.forEachIndexed { i: Int, key: String ->
val button = getInputObject(key)
add(InputBindingSetting(button, Settings.axisTitles[i]))
}
add(HeaderSetting(R.string.controller_dpad_button,R.string.controller_dpad_button_description))
Settings.dPadButtonKeys.forEachIndexed { i: Int, key: String ->
val button = getInputObject(key)
add(InputBindingSetting(button, Settings.dPadTitles[i]))
add(HeaderSetting(R.string.controller_dpad,R.string.controller_dpad_axis_description))
GamepadHelper.dPadButtonKeys.forEachIndexed { i: Int, setting: InputMappingSetting ->
add(InputBindingSetting(setting, settings, GamepadHelper.axisTitles[i]))
}
add(HeaderSetting(R.string.controller_triggers))
Settings.triggerKeys.forEachIndexed { i: Int, key: String ->
val button = getInputObject(key)
add(InputBindingSetting(button, Settings.triggerTitles[i]))
GamepadHelper.triggerKeys.forEachIndexed { i: Int, setting: InputMappingSetting ->
add(InputBindingSetting(setting, settings, GamepadHelper.triggerTitles[i]))
}
add(HeaderSetting(R.string.controller_hotkeys,R.string.controller_hotkeys_description))
Settings.hotKeys.forEachIndexed { i: Int, key: String ->
val button = getInputObject(key)
add(InputBindingSetting(button, Settings.hotkeyTitles[i]))
GamepadHelper.hotKeys.forEachIndexed { i: Int, setting: InputMappingSetting ->
add(InputBindingSetting(setting, settings, GamepadHelper.hotkeyTitles[i]))
}
add(HeaderSetting(R.string.miscellaneous))
add(
SwitchSetting(
@ -807,19 +799,6 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView)
}
}
private fun getInputObject(key: String): AbstractSetting<String> {
return object : AbstractSetting<String> {
override val key = key
override val section = Settings.SECTION_CONTROLS
override val isRuntimeEditable = true
override val defaultValue = ""
override fun valueFromString(string: String): String = string
override fun valueToString(value: String): String = value
// TODO: make input mappings also work per-game, which will be easy if we move
// them to config files
}
}
private fun addGraphicsSettings(sl: ArrayList<SettingsItem>) {
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_graphics))
sl.apply {

View File

@ -15,12 +15,10 @@ import org.citra.citra_emu.features.settings.ui.SettingsAdapter
class InputBindingSettingViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
SettingViewHolder<InputBindingSetting>(binding.root, adapter) {
override lateinit var setting: InputBindingSetting
override fun bind(item: SettingsItem) {
val preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
setting = item as InputBindingSetting
binding.textSettingName.setText(item.nameId)
val uiString = preferences.getString(setting.abstractSetting.key, "")!!
val uiString = setting.value
if (uiString.isNotEmpty()) {
binding.textSettingDescription.visibility = View.GONE
binding.textSettingValue.visibility = View.VISIBLE
@ -39,6 +37,8 @@ class InputBindingSettingViewHolder(val binding: ListItemSettingBinding, adapter
binding.textSettingDescription.alpha = 0.5f
binding.textSettingValue.alpha = 0.5f
}
showGlobalButtonIfNeeded(binding.buttonUseGlobal, position)
}
override fun onClick(clicked: View) {

View File

@ -12,6 +12,7 @@ import org.citra.citra_emu.R
import org.citra.citra_emu.features.settings.model.AbstractSetting
import org.citra.citra_emu.features.settings.model.BooleanSetting
import org.citra.citra_emu.features.settings.model.FloatSetting
import org.citra.citra_emu.features.settings.model.InputMappingSetting
import org.citra.citra_emu.features.settings.model.IntListSetting
import org.citra.citra_emu.features.settings.model.IntSetting
import org.citra.citra_emu.features.settings.model.Settings
@ -37,7 +38,8 @@ object SettingsFile {
IntSetting.values().toList() +
FloatSetting.values().toList() +
StringSetting.values().toList() +
IntListSetting.values().toList()
IntListSetting.values().toList() +
InputMappingSetting.values().toList()
}
private fun findSettingByKey(key: String): AbstractSetting<*>? =
@ -96,6 +98,7 @@ object SettingsFile {
*/
fun loadSettings(settings: Settings, view: SettingsActivityView? = null) {
readFile(getSettingsFile(FILE_NAME_CONFIG),settings,false,view)
settings.inputMappingManager.rebuild(settings)
}
/**
@ -106,6 +109,7 @@ object SettingsFile {
loadSettings(settings, view)
val file = findCustomGameSettingsFile(gameId) ?: return
readFile(file, settings, true, view)
settings.inputMappingManager.rebuild(settings)
}
/**

View File

@ -11,12 +11,14 @@ import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.citra.citra_emu.R
import org.citra.citra_emu.databinding.DialogAutoMapBinding
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting
import org.citra.citra_emu.utils.Log
import org.citra.citra_emu.features.input.GamepadHelper
import org.citra.citra_emu.features.settings.model.SettingsViewModel
/**
* Captures a single button press to detect controller layout (Xbox vs Nintendo)
@ -25,7 +27,7 @@ import org.citra.citra_emu.utils.Log
class AutoMapDialogFragment : BottomSheetDialogFragment() {
private var _binding: DialogAutoMapBinding? = null
private val binding get() = _binding!!
private val settingsViewModel: SettingsViewModel by activityViewModels()
private var onComplete: (() -> Unit)? = null
override fun onCreateView(
@ -72,12 +74,11 @@ class AutoMapDialogFragment : BottomSheetDialogFragment() {
// Check if this is a Nintendo Switch Joy-Con (not Pro Controller).
// Joy-Cons have unique quirks: split devices, non-standard D-pad scan codes,
// partial A/B swap but no X/Y swap from Android's evdev layer.
val isJoyCon = InputBindingSetting.isJoyCon(device)
val isJoyCon = GamepadHelper.isJoyCon(device)
if (isJoyCon) {
Log.info("[AutoMap] Detected Joy-Con - using Joy-Con mappings")
InputBindingSetting.clearAllBindings()
InputBindingSetting.applyJoyConBindings()
GamepadHelper.applyJoyConBindings(settingsViewModel.settings)
onComplete?.invoke()
dismiss()
return true
@ -106,8 +107,7 @@ class AutoMapDialogFragment : BottomSheetDialogFragment() {
val dpadName = if (useAxisDpad) "axis" else "button"
Log.info("[AutoMap] Detected $dpadName d-pad (device=${device?.name})")
InputBindingSetting.clearAllBindings()
InputBindingSetting.applyAutoMapBindings(isNintendoLayout, useAxisDpad)
GamepadHelper.applyAutoMapBindings(settingsViewModel.settings, isNintendoLayout, useAxisDpad)
onComplete?.invoke()
dismiss()

View File

@ -1270,6 +1270,8 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram
binding.surfaceInputOverlay.resetButtonPlacement()
}
fun updateShowPerformanceOverlay() {
if (perfStatsUpdater != null) {
perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!)

View File

@ -56,36 +56,18 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() {
isCancelable = false
view.requestFocus()
view.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() }
if (setting!!.isButtonMappingSupported()) {
dialog?.setOnKeyListener { _, _, event -> onKeyEvent(event) }
}
if (setting!!.isAxisMappingSupported()) {
binding.root.setOnGenericMotionListener { _, event -> onMotionEvent(event) }
}
dialog?.setOnKeyListener { _, _, event -> onKeyEvent(event) }
binding.root.setOnGenericMotionListener { _, event -> onMotionEvent(event) }
val inputTypeId = when {
setting!!.isCirclePad() -> R.string.controller_circlepad
setting!!.isCStick() -> R.string.controller_c
setting!!.isDPad() -> R.string.controller_dpad
setting!!.isTrigger() -> R.string.controller_trigger
else -> R.string.button
}
binding.textTitle.text =
String.format(
getString(R.string.input_dialog_title),
getString(inputTypeId),
getString(setting!!.nameId)
setting!!.value,""
)
var messageResId: Int = R.string.input_dialog_description
if (setting!!.isAxisMappingSupported() && !setting!!.isTrigger()) {
// Use specialized message for axis left/right or up/down
messageResId = if (setting!!.isHorizontalOrientation()) {
R.string.input_binding_description_horizontal_axis
} else {
R.string.input_binding_description_vertical_axis
}
}
val messageResId: Int = R.string.input_dialog_description
binding.textMessage.text = getString(messageResId)
binding.buttonClear.setOnClickListener {
@ -140,7 +122,7 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() {
var numMovedAxis = 0
var axisMoveValue = 0.0f
var lastMovedRange: InputDevice.MotionRange? = null
var lastMovedDir = '?'
var lastMovedDir = 0
if (waitingForEvent) {
for (i in motionRanges.indices) {
val range = motionRanges[i]
@ -164,7 +146,7 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() {
axisMoveValue = origValue
numMovedAxis++
lastMovedRange = range
lastMovedDir = if (origValue < 0.0f) '-' else '+'
lastMovedDir = if (origValue < 0.0f) -1 else 1
}
} else if (abs(origValue) < 0.25f && abs(previousValue) > 0.75f) {
// Special case for d-pads (axis value jumps between 0 and 1 without any values
@ -172,7 +154,7 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() {
// due to the first press being caught by the "if (firstEvent)" case further up.
numMovedAxis++
lastMovedRange = range
lastMovedDir = if (previousValue < 0.0f) '-' else '+'
lastMovedDir = if (previousValue < 0.0f) -1 else 1
}
}
previousValues[i] = origValue
@ -181,7 +163,7 @@ class MotionBottomSheetDialogFragment : BottomSheetDialogFragment() {
// If only one axis moved, that's the winner.
if (numMovedAxis == 1) {
waitingForEvent = false
setting?.onMotionInput(input, lastMovedRange!!, lastMovedDir)
setting?.onMotionInput(lastMovedRange!!, lastMovedDir)
dismiss()
}
}

View File

@ -173,7 +173,7 @@ class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(contex
continue
}
anyOverlayStateChanged = true
// TODO - switch these to using standard hotkey buttons instead of nativelibrary buttons
if (button.id == NativeLibrary.ButtonType.BUTTON_SWAP && button.status == NativeLibrary.ButtonState.PRESSED) {
swapScreen()
}

View File

@ -1,69 +0,0 @@
// Copyright 2023 Citra Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.citra.citra_emu.utils
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
/**
* Some controllers have incorrect mappings. This class has special-case fixes for them.
*/
object ControllerMappingHelper {
/**
* Some controllers report extra button presses that can be ignored.
*/
fun shouldKeyBeIgnored(inputDevice: InputDevice, keyCode: Int): Boolean {
return if (isDualShock4(inputDevice)) {
// The two analog triggers generate analog motion events as well as a keycode.
// We always prefer to use the analog values, so throw away the button press
keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2
} else false
}
/**
* Scale an axis to be zero-centered with a proper range.
*/
fun scaleAxis(inputDevice: InputDevice, axis: Int, value: Float): Float {
if (isDualShock4(inputDevice)) {
// Android doesn't have correct mappings for this controller's triggers. It reports them
// as RX & RY, centered at -1.0, and with a range of [-1.0, 1.0]
// Scale them to properly zero-centered with a range of [0.0, 1.0].
if (axis == MotionEvent.AXIS_RX || axis == MotionEvent.AXIS_RY) {
return (value + 1) / 2.0f
}
} else if (isXboxOneWireless(inputDevice)) {
// Same as the DualShock 4, the mappings are missing.
if (axis == MotionEvent.AXIS_Z || axis == MotionEvent.AXIS_RZ) {
return (value + 1) / 2.0f
}
if (axis == MotionEvent.AXIS_GENERIC_1) {
// This axis is stuck at ~.5. Ignore it.
return 0.0f
}
} else if (isMogaPro2Hid(inputDevice)) {
// This controller has a broken axis that reports a constant value. Ignore it.
if (axis == MotionEvent.AXIS_GENERIC_1) {
return 0.0f
}
}
return value
}
private fun isDualShock4(inputDevice: InputDevice): Boolean {
// Sony DualShock 4 controller
return inputDevice.vendorId == 0x54c && inputDevice.productId == 0x9cc
}
private fun isXboxOneWireless(inputDevice: InputDevice): Boolean {
// Microsoft Xbox One controller
return inputDevice.vendorId == 0x45e && inputDevice.productId == 0x2e0
}
private fun isMogaPro2Hid(inputDevice: InputDevice): Boolean {
// Moga Pro 2 HID
return inputDevice.vendorId == 0x20d6 && inputDevice.productId == 0x6271
}
}

View File

@ -141,21 +141,17 @@ void Config::ReadSetting(const std::string& group, Settings::Setting<Type, range
}
void Config::ReadValues() {
// Controls
// Controls - Always use the default_buttons and default_analogs on the native side, don't
// actually read the INI file. It is only used by Kotlin, which will correctly dispatch
// the codes as needed.
for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) {
std::string default_param = InputManager::GenerateButtonParamPackage(default_buttons[i]);
Settings::values.current_input_profile.buttons[i] = android_config->GetString(
"Controls", Settings::NativeButton::mapping[i], default_param);
if (Settings::values.current_input_profile.buttons[i].empty())
Settings::values.current_input_profile.buttons[i] = default_param;
Settings::values.current_input_profile.buttons[i] =
InputManager::GenerateButtonParamPackage(default_buttons[i]);
}
for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) {
std::string default_param = InputManager::GenerateAnalogParamPackage(default_analogs[i]);
Settings::values.current_input_profile.analogs[i] = android_config->GetString(
"Controls", Settings::NativeAnalog::mapping[i], default_param);
if (Settings::values.current_input_profile.analogs[i].empty())
Settings::values.current_input_profile.analogs[i] = default_param;
Settings::values.current_input_profile.analogs[i] =
InputManager::GenerateAnalogParamPackage(default_analogs[i]);
}
Settings::values.current_input_profile.motion_device = android_config->GetString(

View File

@ -77,6 +77,39 @@ static const char* android_config_default_file_content = (BOOST_HANA_STRING(R"(
# Use Artic Controller when connected to Artic Base Server. (Default 0)
)") DECLARE_KEY(use_artic_base_controller) BOOST_HANA_STRING(R"(
# Configuration strings for 3ds buttons and axes. On Android, all such strings use "engine:gamepad" once bound. Default unbound.
)") DECLARE_KEY(button_a) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_b) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_x) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_y) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_l) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_r) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_zl) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_zr) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_start) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_select) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(button_home) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(dpad_up) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(dpad_down) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(dpad_left) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(dpad_right) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(circlepad_up) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(circlepad_down) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(circlepad_left) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(circlepad_right) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(cstick_up) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(cstick_down) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(cstick_left) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(cstick_right) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_close) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_cycle_layout) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_enable) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_pause_resume) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_quickload) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_quicksave) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_swap) BOOST_HANA_STRING(R"(
)") DECLARE_KEY(hotkey_turbo_limit) BOOST_HANA_STRING(R"(
[Core]
# Whether to use the Just-In-Time (JIT) compiler for CPU emulation
# 0: Interpreter (slow), 1 (default): JIT (fast)
@ -88,6 +121,7 @@ static const char* android_config_default_file_content = (BOOST_HANA_STRING(R"(
# Range is any positive integer (but we suspect 25 - 400 is a good idea) Default is 100
)") DECLARE_KEY(cpu_clock_percentage) BOOST_HANA_STRING(R"(
[Renderer]
# Whether to render using OpenGL
# 1: OpenGL ES (default), 2: Vulkan