diff --git a/app/src/main/java/xendroid/compose/AppContainer.kt b/app/src/main/java/xendroid/compose/AppContainer.kt index 4e701e195c..ca70cd9234 100644 --- a/app/src/main/java/xendroid/compose/AppContainer.kt +++ b/app/src/main/java/xendroid/compose/AppContainer.kt @@ -144,7 +144,7 @@ class AppContainer(context: Context) { require(modelClass == KeymapViewModel::class.java) { "Unknown ViewModel ${modelClass.name}" } - return KeymapViewModel(keymapStore) as T + return KeymapViewModel(appContext, keymapStore) as T } } } diff --git a/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt b/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt index 43b728e37d..419f4f3f41 100644 --- a/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt +++ b/app/src/main/java/xendroid/compose/EmulatorHostActivity.kt @@ -67,6 +67,8 @@ import xendroid.compose.ui.pause.PAUSE_OPTION_TOUCH_OVERLAY import xendroid.compose.ui.pause.PAUSE_OPTION_RESUME import xendroid.compose.ui.pause.PauseMenuPanel import xendroid.compose.ui.theme.xendroidTheme +import xendroid.compose.gamepad.ControllerRegistry +import xendroid.compose.gamepad.RumbleDriver import xendroid.compose.gamepad.GamepadConfigDto import xendroid.compose.gamepad.GamepadController import xendroid.compose.gamepad.GamepadOverlay @@ -85,6 +87,8 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { companion object { private const val TAG = "EmuHost" private const val KEYBOARD_POLL_MS = 150L + private const val RUMBLE_POLL_MS = 32L + private const val TRIGGER_DEADZONE = 0.02f const val EXTRA_GAME_URI = "game_uri" // keys must match GameLibraryViewModel const val EXTRA_DISC_LABELS = "disc_labels" const val EXTRA_DISC_PATHS = "disc_paths" @@ -138,10 +142,12 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { // Falls back to GameButtons.DEFAULT_LOOKUP until KeymapStore loads in onCreate. @Volatile private var keyMap: Map = GameButtons.DEFAULT_LOOKUP + @Volatile private var keyMapByDevice: Map> = emptyMap() + // Cached per Android device id so a keystroke never re-queries InputDevice. + private val keyMapForDevice = HashMap>() private var vibrator: Vibrator? = null - // Edge-detect state for analog triggers reported as axes. - private var lTriggerDown = false - private var rTriggerDown = false + private val controllers = ControllerRegistry(session) + private val rumble by lazy { RumbleDriver(this) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -151,6 +157,8 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { // notice if the main process dies anyway. EmuProcessLink.bindToMainProcess(this) + controllers.start(this) + val gameUri = FrontendLaunch.resolveGamePath(this, intent) if (gameUri.isNullOrEmpty()) { Log.e(TAG, "No bootable game in launch intent; finishing") @@ -170,6 +178,10 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { EmulatorRuntime.ensureLoaded() // idempotent; lazy on Adreno 5xx/6xx store.androidToGameKey.firstOrNull() ?: GameButtons.DEFAULT_LOOKUP } + keyMapByDevice = withContext(Dispatchers.IO) { + store.androidToGameKeyByDevice.firstOrNull() ?: emptyMap() + } + synchronized(keyMapForDevice) { keyMapForDevice.clear() } // show_debug_overlay lives in the native TOML config, not SharedPreferences; // read it off-main via ConfigStore. showFpsOverlay.value = withContext(Dispatchers.IO) { readShowDebugOverlay() } @@ -289,7 +301,7 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { onUserInteraction = poke, onKeyEvent = { kc, pressed, v -> if (pressed && v == Kc.VALUE_UNUSED) maybeVibrate() - session.keyEvent(kc, pressed, v) + session.keyEvent(controllers.touchSlot(), kc, pressed, v) }, modifier = Modifier.fillMaxSize(), ) @@ -393,6 +405,22 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { ) } } + + LaunchedEffect(booted) { + if (!booted) return@LaunchedEffect + try { + while (isActive) { + val state = session.vibrationState() + if (state.isNotEmpty()) { + rumble.apply(state) { slot -> vibratorForSlot(slot) } + } + delay(RUMBLE_POLL_MS) + } + } finally { + rumble.stopAll { slot -> vibratorForSlot(slot) } + } + } + } // Back / swipe-back PAUSES the game and opens a Quit menu instead of leaving to @@ -485,6 +513,7 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun onPause() { + rumble.stopAll { slot -> vibratorForSlot(slot) } super.onPause() session.flushGpuCaches() } @@ -514,6 +543,7 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onDestroy() { super.onDestroy() + controllers.stop() // A pending prompt must not hold a dispatch thread through teardown. keyboardRequestState.value = null session.keyboardCancelAll() @@ -534,10 +564,10 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { // Swallow the rest so neither the game nor an OEM overlay reacts behind it. if (consumeIfGamepad(event)) return true } - val gameKey = keyMap[keyCode] + val gameKey = keyMapFor(event.deviceId)[keyCode] ?: return consumeIfGamepad(event) || super.onKeyDown(keyCode, event) if (event.repeatCount == 0) { - session.keyEvent(gameKey, true, KEY_VALUE_UNUSED) + session.keyEvent(deviceSlotFor(event.deviceId), gameKey, true, KEY_VALUE_UNUSED) return true } return super.onKeyDown(keyCode, event) // ignore auto-repeats @@ -547,12 +577,41 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { if (panelNav() != null && (isPanelKey(keyCode) || consumeIfGamepad(event))) { return true } - val gameKey = keyMap[keyCode] + val gameKey = keyMapFor(event.deviceId)[keyCode] ?: return consumeIfGamepad(event) || super.onKeyUp(keyCode, event) - session.keyEvent(gameKey, false, KEY_VALUE_UNUSED) + session.keyEvent(deviceSlotFor(event.deviceId), gameKey, false, KEY_VALUE_UNUSED) return true } + // Only the first pad may claim the console's own motor, so a second motorless + // pad cannot buzz the handheld on player 1's behalf. + private fun vibratorForSlot(deviceSlot: Int) = + rumble.vibratorForDevice( + controllers.androidDeviceIdFor(deviceSlot), + fallbackToSystem = deviceSlot == controllers.firstPadSlot(), + ) + + + + + /** Wraps past player 4 to unmapped. */ + + /** Keyboards and remotes keep feeding the overlay's slot. */ + /** A device's own mapping if it declared one, else the shared mapping. */ + private fun keyMapFor(deviceId: Int): Map { + if (keyMapByDevice.isEmpty()) return keyMap + synchronized(keyMapForDevice) { keyMapForDevice[deviceId] }?.let { return it } + val descriptor = InputDevice.getDevice(deviceId)?.descriptor + val resolved = descriptor + ?.let { keyMapByDevice[KeymapStore.sanitize(it)] } + ?: keyMap + synchronized(keyMapForDevice) { keyMapForDevice[deviceId] = resolved } + return resolved + } + + private fun deviceSlotFor(deviceId: Int): Int = + controllers.padFor(deviceId)?.deviceSlot ?: controllers.touchSlot() + /** Consumes unmapped controller buttons (never BACK): unhandled gamepad input is what * OEM overlays latch onto. */ private fun consumeIfGamepad(event: KeyEvent): Boolean = @@ -569,97 +628,91 @@ class EmulatorHostActivity : ComponentActivity(), SurfaceHolder.Callback { panelHat(nav, event) return true } + val pad = controllers.padFor(event.deviceId) ?: return super.onGenericMotionEvent(event) // No early return: sticks/triggers must still process while a hat is held. - val hatHandled = isNonDpadSource(event) && handleHat(event) + val hatHandled = isNonDpadSource(event) && handleHat(pad, event) if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK) { return hatHandled || super.onGenericMotionEvent(event) } - emitAxisPair(event.getAxisValue(MotionEvent.AXIS_X), + emitAxisPair(pad, event.getAxisValue(MotionEvent.AXIS_X), negKey = KC_LTHUMB_LEFT, posKey = KC_LTHUMB_RIGHT, invert = false) - emitAxisPair(event.getAxisValue(MotionEvent.AXIS_Y), + emitAxisPair(pad, event.getAxisValue(MotionEvent.AXIS_Y), negKey = KC_LTHUMB_UP, posKey = KC_LTHUMB_DOWN, invert = true) - emitAxisPair(event.getAxisValue(MotionEvent.AXIS_Z), + emitAxisPair(pad, event.getAxisValue(MotionEvent.AXIS_Z), negKey = KC_RTHUMB_LEFT, posKey = KC_RTHUMB_RIGHT, invert = false) - emitAxisPair(event.getAxisValue(MotionEvent.AXIS_RZ), + emitAxisPair(pad, event.getAxisValue(MotionEvent.AXIS_RZ), negKey = KC_RTHUMB_UP, posKey = KC_RTHUMB_DOWN, invert = true) // Most pads report triggers as ANALOG axes (LTRIGGER/RTRIGGER, or BRAKE/GAS). Emit a // press past the threshold, edge-detected so we don't spam. - lTriggerDown = emitTrigger( + emitTrigger(pad, maxOf(event.getAxisValue(MotionEvent.AXIS_LTRIGGER), event.getAxisValue(MotionEvent.AXIS_BRAKE)), - KC_TRIGGER_L, lTriggerDown) - rTriggerDown = emitTrigger( + KC_TRIGGER_L) + emitTrigger(pad, maxOf(event.getAxisValue(MotionEvent.AXIS_RTRIGGER), event.getAxisValue(MotionEvent.AXIS_GAS)), - KC_TRIGGER_R, rTriggerDown) + KC_TRIGGER_R) return true } - /** Analog trigger -> digital game key. Emits only on the down/up edge (>0.5 = down). */ - private fun emitTrigger(value: Float, gameKey: Int, wasDown: Boolean): Boolean { - val down = value > 0.5f - if (down != wasDown) session.keyEvent(gameKey, down, KEY_VALUE_UNUSED) - return down + /** Analog trigger -> the guest's 0-255 trigger byte. Thresholding it to a + * button threw away the whole travel range. */ + private fun emitTrigger(pad: ControllerRegistry.PadState, value: Float, gameKey: Int) { + val v = if (value < TRIGGER_DEADZONE) 0f else value + val scaled = (v * 255f).toInt().coerceIn(0, 255) + emitAxis(pad, gameKey, scaled > 0, scaled) } /** For one axis emit the opposing thumb directions, scaled to signed-short range. * invert flips sign first (screen Y is up-negative; X360 up is positive). */ - private fun emitAxisPair(axis: Float, negKey: Int, posKey: Int, invert: Boolean) { + private fun emitAxisPair(pad: ControllerRegistry.PadState, axis: Float, negKey: Int, posKey: Int, invert: Boolean) { val raw = if (invert) -axis else axis // Snap to exactly zero so emitAxis' equality check can match. val v = if (abs(raw) < AXIS_DEADZONE) 0f else raw when { v < 0f -> { - emitAxis(posKey, false, 0) - emitAxis(negKey, true, (v * 32768f).toInt()) + emitAxis(pad, posKey, false, 0) + emitAxis(pad, negKey, true, (v * 32768f).toInt()) } v > 0f -> { - emitAxis(negKey, false, 0) - emitAxis(posKey, true, (v * 32767f).toInt()) + emitAxis(pad, negKey, false, 0) + emitAxis(pad, posKey, true, (v * 32767f).toInt()) } else -> { - emitAxis(negKey, false, 0) - emitAxis(posKey, false, 0) + emitAxis(pad, negKey, false, 0) + emitAxis(pad, posKey, false, 0) } } } - // Last value pushed per analog code: motion events arrive per sample (~120Hz x 4 axes) - // and mostly repeat, so without this every sample costs ~10 JNI calls. - private val axisPressed = BooleanArray(24) - private val axisValue = IntArray(24) { Int.MIN_VALUE } - - private fun emitAxis(code: Int, pressed: Boolean, value: Int) { - if (axisPressed[code] == pressed && axisValue[code] == value) return - axisPressed[code] = pressed - axisValue[code] = value - session.keyEvent(code, pressed, value) + // Last value pushed per analog code (per pad): motion events arrive per sample + // (~120Hz x 4 axes) and mostly repeat, so without this every sample costs ~10 JNI calls. + private fun emitAxis(pad: ControllerRegistry.PadState, code: Int, pressed: Boolean, value: Int) { + if (pad.axisPressed[code] == pressed && pad.axisValue[code] == value) return + pad.axisPressed[code] = pressed + pad.axisValue[code] = value + session.keyEvent(pad.deviceSlot, code, pressed, value) } - // Hat D-pad state, edge-detected: the hat only releases what IT pressed, so it can't - // clobber a D-pad held via real KEYCODE_DPAD_* key events. - private var hatLeft = false - private var hatUp = false - private var hatRight = false - private var hatDown = false - - /** Hat axes -> D-pad; thresholds, not ==+-1f (some pads are inexact). */ - private fun handleHat(event: MotionEvent): Boolean { + /** Hat axes -> D-pad; thresholds, not ==+-1f (some pads are inexact). Edge-detected per + * pad: the hat only releases what IT pressed, so it can't clobber a D-pad held via real + * KEYCODE_DPAD_* key events. */ + private fun handleHat(pad: ControllerRegistry.PadState, event: MotionEvent): Boolean { val hx = event.getAxisValue(MotionEvent.AXIS_HAT_X) val hy = event.getAxisValue(MotionEvent.AXIS_HAT_Y) val left = hx < -0.5f val right = hx > 0.5f val up = hy < -0.5f val down = hy > 0.5f - if (left != hatLeft) { - session.keyEvent(KC_DPAD_LEFT, left, KEY_VALUE_UNUSED); hatLeft = left + if (left != pad.hatLeft) { + session.keyEvent(pad.deviceSlot, KC_DPAD_LEFT, left, KEY_VALUE_UNUSED); pad.hatLeft = left } - if (right != hatRight) { - session.keyEvent(KC_DPAD_RIGHT, right, KEY_VALUE_UNUSED); hatRight = right + if (right != pad.hatRight) { + session.keyEvent(pad.deviceSlot, KC_DPAD_RIGHT, right, KEY_VALUE_UNUSED); pad.hatRight = right } - if (up != hatUp) { - session.keyEvent(KC_DPAD_UP, up, KEY_VALUE_UNUSED); hatUp = up + if (up != pad.hatUp) { + session.keyEvent(pad.deviceSlot, KC_DPAD_UP, up, KEY_VALUE_UNUSED); pad.hatUp = up } - if (down != hatDown) { - session.keyEvent(KC_DPAD_DOWN, down, KEY_VALUE_UNUSED); hatDown = down + if (down != pad.hatDown) { + session.keyEvent(pad.deviceSlot, KC_DPAD_DOWN, down, KEY_VALUE_UNUSED); pad.hatDown = down } return left || right || up || down } diff --git a/app/src/main/java/xendroid/compose/MainActivity.kt b/app/src/main/java/xendroid/compose/MainActivity.kt index c363e66fb9..92c9d2a0fd 100644 --- a/app/src/main/java/xendroid/compose/MainActivity.kt +++ b/app/src/main/java/xendroid/compose/MainActivity.kt @@ -17,7 +17,15 @@ import xendroid.compose.core.FrontendLaunch import xendroid.compose.ui.library.ACTION_LAUNCH_GAME import xendroid.compose.ui.library.EXTRA_GAME_URI import xendroid.compose.core.SessionLogs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import androidx.compose.runtime.remember +import xendroid.compose.gamepad.PadProfiles +import xendroid.compose.gamepad.PlayerAssignment +import xendroid.compose.gamepad.PlayerSetup +import xendroid.compose.gamepad.rememberConnectedPads import xendroid.compose.ui.AppNavHost +import xendroid.compose.ui.players.PlayerSetupDialog import xendroid.compose.ui.theme.xendroidTheme import xendroid.compose.settings.ConfigStore import xendroid.compose.settings.seedTouchOverlayDefault @@ -78,6 +86,42 @@ class MainActivity : ComponentActivity() { AppNavHost(container) + // Who plays as whom, re-decided whenever a controller comes or + // goes. One controller keeps the old behaviour: it just drives the + // signed-in profile. + var playerSetup by remember { mutableStateOf?>(null) } + var playerProfiles by remember { mutableStateOf>>(emptyList()) } + val pads = rememberConnectedPads() + LaunchedEffect(pads.joinToString(",") { it.descriptor }) { + if (frontendGame != null) return@LaunchedEffect + val rows = withContext(Dispatchers.IO) { + EmulatorRuntime.ensureLoaded() + if (!PadProfiles.askOnStart(applicationContext)) return@withContext null + if (pads.size < 2) { + PlayerSetup.applySolo(applicationContext) + return@withContext null + } + PlayerSetup.ensureEnoughProfiles(applicationContext) + playerProfiles = PlayerSetup.profiles() + PlayerSetup.defaults(applicationContext) + } + playerSetup = rows + } + playerSetup?.let { rows -> + PlayerSetupDialog( + pads = pads, + profiles = playerProfiles, + initial = rows, + onConfirm = { chosen -> + playerSetup = null + lifecycleScope.launch(Dispatchers.IO) { + PlayerSetup.apply(applicationContext, chosen) + } + }, + onDismiss = { playerSetup = null }, + ) + } + LaunchedEffect(Unit) { if (frontendGame != null) return@LaunchedEffect // A debug build's -debug versionName never matches a release tag, so the diff --git a/app/src/main/java/xendroid/compose/core/EmulatorSession.kt b/app/src/main/java/xendroid/compose/core/EmulatorSession.kt index ea619a3615..e00023e33c 100644 --- a/app/src/main/java/xendroid/compose/core/EmulatorSession.kt +++ b/app/src/main/java/xendroid/compose/core/EmulatorSession.kt @@ -79,9 +79,43 @@ class EmulatorSession { * signed short magnitude for thumbsticks. Dropped until boot() has run: native key_event * unconditionally derefs g_windowed_app_ref, null until the detached boot thread sets it * (a controller press during the boot splash would crash). */ - fun keyEvent(keyCode: Int, pressed: Boolean, value: Int) { + fun keyEvent(deviceSlot: Int, keyCode: Int, pressed: Boolean, value: Int) { + if (!booted || deviceSlot < 0) return + core.key_event(deviceSlot, keyCode, pressed, value) + } + + /** -1 when not booted or the driver is full. */ + fun attachInputDevice(stableId: String, displayName: String, subtype: Int, preferredSlot: Int): Int { + if (!booted) return -1 + return runCatching { core.input_attach_device(stableId, displayName, subtype, preferredSlot) } + .getOrDefault(-1) + } + + fun detachInputDevice(deviceSlot: Int) { + if (!booted || deviceSlot < 0) return + runCatching { core.input_detach_device(deviceSlot) } + } + + fun bindInputSlot(guestSlot: Int, deviceSlot: Int) { if (!booted) return - core.key_event(keyCode, pressed, value) + runCatching { core.input_bind_slot(guestSlot, deviceSlot) } + } + + fun unbindInputSlot(guestSlot: Int) { + if (!booted) return + runCatching { core.input_unbind_slot(guestSlot) } + } + + fun vibrationState(): IntArray { + if (!booted) return IntArray(0) + return runCatching { core.input_vibration_state() ?: IntArray(0) } + .getOrDefault(IntArray(0)) + } + + fun listInputDevices(): List { + if (!booted) return emptyList() + return runCatching { core.input_list_devices()?.toList() ?: emptyList() } + .getOrDefault(emptyList()) } // ---- Debug stats (UI-thread polled; reads native lock-free atomics) ---- diff --git a/app/src/main/java/xendroid/compose/data/KeymapStore.kt b/app/src/main/java/xendroid/compose/data/KeymapStore.kt index 0bece64fd9..b9901fe93c 100644 --- a/app/src/main/java/xendroid/compose/data/KeymapStore.kt +++ b/app/src/main/java/xendroid/compose/data/KeymapStore.kt @@ -10,41 +10,106 @@ import kotlinx.coroutines.flow.map /** Persists the 16-button hardware-key mapping + the vibrate toggle in the shared * "xendroid_prefs" DataStore. Keyed by STABLE button index (keymap_0..keymap_15), - * NOT the legacy resId-as-string keys. Value = bound Android keycode (0 = cleared). */ + * NOT the legacy resId-as-string keys. Value = bound Android keycode (0 = cleared). + * + * A controller may override the shared mapping: its rows live under + * keymap__, where is the sanitized InputDevice + * descriptor (stable across reconnects). Absent rows inherit the shared + * mapping, so existing users keep theirs and a new pad starts from it. */ class KeymapStore(private val appContext: Context) { private fun key(index: Int) = intPreferencesKey("keymap_$index") + private fun key(device: String, index: Int) = + intPreferencesKey("keymap_${sanitize(device)}_$index") + private fun overrideFlag(device: String) = + booleanPreferencesKey("keymap_${sanitize(device)}_own") private val vibrateKey = booleanPreferencesKey("enable_vibrator") - /** button index 0..15 -> bound Android keycode (default-filled where absent). */ - val bindings: Flow> = - appContext.dataStore.data.map { prefs -> readBindings(prefs) } + companion object { + /** Descriptors are opaque and may contain characters we would rather not + * concatenate into a key; the mapping only has to be stable and unique. */ + fun sanitize(device: String): String = + device.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("") + } + + /** A blank descriptor means the shared mapping itself. */ + fun bindingsFor(device: String): Flow> = + appContext.dataStore.data.map { prefs -> + readBindings(prefs, device.ifBlank { null }) + } + + fun hasOwnMapping(device: String): Flow = + appContext.dataStore.data.map { it[overrideFlag(device)] ?: false } /** Android keycode -> game KEY_CODE, ready for the host lookup. Unbound (0) dropped. */ val androidToGameKey: Flow> = + appContext.dataStore.data.map { prefs -> lookupFrom(readBindings(prefs, null)) } + + /** The host resolves a key event through its device's entry, else the shared one. */ + val androidToGameKeyByDevice: Flow>> = appContext.dataStore.data.map { prefs -> - val out = HashMap(GameButtons.ALL.size) - for (b in GameButtons.ALL) { - val code = prefs[key(b.index)] ?: b.defaultAndroidKey - if (code != 0) out[code] = b.keyCode + val out = HashMap>() + for ((k, v) in prefs.asMap()) { + val name = k.name + if (!name.startsWith("keymap_") || !name.endsWith("_own")) continue + if (v != true) continue + val device = name.removePrefix("keymap_").removeSuffix("_own") + out[device] = lookupFrom(readBindingsSanitized(prefs, device)) } out } + private fun lookupFrom(bindings: Map): Map { + val out = HashMap(GameButtons.ALL.size) + for (b in GameButtons.ALL) { + val code = bindings[b.index] ?: b.defaultAndroidKey + if (code != 0) out[code] = b.keyCode + } + return out + } + val vibrateEnabled: Flow = appContext.dataStore.data.map { it[vibrateKey] ?: false } - suspend fun setBinding(index: Int, androidKeyCode: Int) { - appContext.dataStore.edit { it[key(index)] = androidKeyCode } + /** [device] null edits the shared mapping; a controller's own is seeded from + * the shared rows on first touch, so the screen never jumps. */ + suspend fun setBinding(device: String?, index: Int, androidKeyCode: Int) { + appContext.dataStore.edit { prefs -> + if (device == null) { + prefs[key(index)] = androidKeyCode + return@edit + } + if (prefs[overrideFlag(device)] != true) { + for (b in GameButtons.ALL) { + prefs[key(device, b.index)] = prefs[key(b.index)] ?: b.defaultAndroidKey + } + prefs[overrideFlag(device)] = true + } + prefs[key(device, index)] = androidKeyCode + } } /** Clear a single binding (stores 0 = unbound, matching legacy "Clear"). */ - suspend fun clearBinding(index: Int) = setBinding(index, 0) + suspend fun clearBinding(device: String?, index: Int) = setBinding(device, index, 0) /** Reset all 16 rows to KeyMapConfig.DEFAULT_KEYMAPPERS. */ - suspend fun resetToDefaults() { + suspend fun resetToDefaults(device: String?) { + appContext.dataStore.edit { prefs -> + for (b in GameButtons.ALL) { + if (device == null) { + prefs[key(b.index)] = b.defaultAndroidKey + } else { + prefs[key(device, b.index)] = b.defaultAndroidKey + } + } + if (device != null) prefs[overrideFlag(device)] = true + } + } + + suspend fun useSharedMapping(device: String) { appContext.dataStore.edit { prefs -> - for (b in GameButtons.ALL) prefs[key(b.index)] = b.defaultAndroidKey + prefs.remove(overrideFlag(device)) + for (b in GameButtons.ALL) prefs.remove(key(device, b.index)) } } @@ -52,9 +117,16 @@ class KeymapStore(private val appContext: Context) { appContext.dataStore.edit { it[vibrateKey] = enabled } } - private fun readBindings(prefs: Preferences): Map { + private fun readBindings(prefs: Preferences, device: String?): Map = + readBindingsSanitized(prefs, device?.let { sanitize(it) }) + + private fun readBindingsSanitized(prefs: Preferences, device: String?): Map { val out = HashMap(GameButtons.ALL.size) - for (b in GameButtons.ALL) out[b.index] = prefs[key(b.index)] ?: b.defaultAndroidKey + for (b in GameButtons.ALL) { + val shared = prefs[key(b.index)] ?: b.defaultAndroidKey + out[b.index] = if (device == null) shared + else prefs[intPreferencesKey("keymap_${device}_${b.index}")] ?: shared + } return out } } diff --git a/app/src/main/java/xendroid/compose/gamepad/ControllerRegistry.kt b/app/src/main/java/xendroid/compose/gamepad/ControllerRegistry.kt new file mode 100644 index 0000000000..926b26a104 --- /dev/null +++ b/app/src/main/java/xendroid/compose/gamepad/ControllerRegistry.kt @@ -0,0 +1,145 @@ +package xendroid.compose.gamepad + +import android.content.Context +import android.hardware.input.InputManager +import android.util.Log +import android.view.InputDevice +import android.view.MotionEvent +import xendroid.compose.core.EmulatorSession + +/** + * Maps Android input devices onto emulator device slots, and keeps edge-detection + * state per pad: one shared set let two pads cancel each other's presses. + * + * Main thread only: input events and InputManager callbacks both arrive there. + */ +class ControllerRegistry(private val session: EmulatorSession) { + + private var appContext: Context? = null + + class PadState(val deviceSlot: Int) { + val axisPressed = BooleanArray(24) + val axisValue = IntArray(24) { Int.MIN_VALUE } + var hatLeft = false + var hatUp = false + var hatRight = false + var hatDown = false + } + + private val pads = HashMap() + // Keyboards/remotes raise key events too; remembered so every keystroke does + // not re-query InputDevice. + private val nonPads = HashSet() + private var touchSlot = -1 + private var listener: InputManager.InputDeviceListener? = null + private var inputManager: InputManager? = null + + /** Attached up front so touch works with no pad. */ + fun touchSlot(): Int { + if (touchSlot < 0) { + touchSlot = session.attachInputDevice(TOUCH_ID, "On-screen controls", SUBTYPE_GAMEPAD, 0) + } + return touchSlot + } + + fun start(context: Context) { + appContext = context.applicationContext + touchSlot() + val manager = context.getSystemService(Context.INPUT_SERVICE) as? InputManager ?: return + inputManager = manager + refresh() + val l = object : InputManager.InputDeviceListener { + override fun onInputDeviceAdded(deviceId: Int) { padFor(deviceId) } + override fun onInputDeviceRemoved(deviceId: Int) { remove(deviceId) } + override fun onInputDeviceChanged(deviceId: Int) {} + } + listener = l + manager.registerInputDeviceListener(l, null) + } + + fun stop() { + listener?.let { inputManager?.unregisterInputDeviceListener(it) } + listener = null + inputManager = null + } + + /** Null for the on-screen overlay, whose rumble belongs to the phone itself. */ + fun androidDeviceIdFor(deviceSlot: Int): Int? = + pads.entries.firstOrNull { it.value.deviceSlot == deviceSlot }?.key + + /** Lowest driver slot held by a physical pad, -1 with none attached. */ + fun firstPadSlot(): Int = pads.values.minOfOrNull { it.deviceSlot } ?: -1 + + /** Attach is a no-op once a device is known, and retried while boot is pending. */ + fun refresh() { + InputDevice.getDeviceIds().forEach { padFor(it) } + } + + fun padFor(deviceId: Int): PadState? { + pads[deviceId]?.let { return it } + if (deviceId in nonPads) return null + val device = InputDevice.getDevice(deviceId) ?: return null + if (device.isVirtual || !isGamepad(device)) { + nonPads.add(deviceId) + return null + } + val stableId = device.descriptor ?: "android-pad-$deviceId" + val preferred = appContext?.let { PadSlots.preferredSlot(it, stableId) } ?: PadSlots.AUTO + val slot = session.attachInputDevice( + stableId, device.name ?: "Controller", SUBTYPE_GAMEPAD, preferred) + if (slot < 0) return null + logAxes(device, slot) + val state = PadState(slot) + pads[deviceId] = state + // Binding is explicit, not left to auto-placement: reconciliation only + // honors a preferred slot while that slot is FREE, and the on-screen + // overlay already holds player 1 - so an auto-placed pad lands one slot + // late and pushes the next pad off the end. + if (preferred != PadSlots.AUTO) { + session.bindInputSlot(preferred, slot) + } else if (pads.size == 1) { + session.bindInputSlot(0, slot) + } + logBindings("attach ${device.name}") + return state + } + + private fun remove(deviceId: Int) { + nonPads.remove(deviceId) + val state = pads.remove(deviceId) ?: return + session.detachInputDevice(state.deviceSlot) + if (pads.isEmpty() && touchSlot >= 0) { + session.bindInputSlot(0, touchSlot) + } + logBindings("detach id=$deviceId") + } + + private fun logBindings(reason: String) { + val rows = session.listInputDevices().joinToString(", ") { d -> + "p${d.guest_slot + 1}<-drv${d.device_slot} '${d.display_name ?: "?"}'" + } + Log.i(TAG, "bindings ($reason): $rows") + } + + /** Which axes a pad publishes decides whether its triggers arrive as + * LTRIGGER/BRAKE or as Z/RZ (which we treat as the right stick). */ + private fun logAxes(device: InputDevice, slot: Int) { + val axes = device.motionRanges.joinToString(", ") { range -> + "${MotionEvent.axisToString(range.axis)}[${range.min}..${range.max} flat=${range.flat}]" + } + Log.i(TAG, "pad slot=$slot '${device.name}' id=${device.id} descriptor=${device.descriptor} " + + "sources=0x${Integer.toHexString(device.sources)} axes: $axes") + } + + private fun isGamepad(device: InputDevice): Boolean { + val sources = device.sources + return sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || + sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK + } + + private companion object { + const val TAG = "XenDroidPads" + const val TOUCH_ID = "android-touch" + const val SUBTYPE_GAMEPAD = 0x01 + } +} diff --git a/app/src/main/java/xendroid/compose/gamepad/GamepadDevices.kt b/app/src/main/java/xendroid/compose/gamepad/GamepadDevices.kt new file mode 100644 index 0000000000..65d1c460e7 --- /dev/null +++ b/app/src/main/java/xendroid/compose/gamepad/GamepadDevices.kt @@ -0,0 +1,25 @@ +package xendroid.compose.gamepad + +import android.view.InputDevice + +/** [descriptor] is stable across reconnects, which is what mappings are keyed on. */ +data class GamepadDevice(val descriptor: String, val name: String) + +/** Deduplicated by descriptor: Android reports one physical pad as several + * device ids (gamepad and joystick sources). */ +fun connectedGamepads(): List { + val out = LinkedHashMap() + for (id in InputDevice.getDeviceIds()) { + val device = InputDevice.getDevice(id) ?: continue + if (device.isVirtual || !isGamepadSource(device.sources)) continue + val descriptor = device.descriptor ?: continue + out.getOrPut(descriptor) { + GamepadDevice(descriptor, device.name ?: "Controller") + } + } + return out.values.toList() +} + +fun isGamepadSource(sources: Int): Boolean = + sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || + sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK diff --git a/app/src/main/java/xendroid/compose/gamepad/PadProfiles.kt b/app/src/main/java/xendroid/compose/gamepad/PadProfiles.kt new file mode 100644 index 0000000000..e0fa91061f --- /dev/null +++ b/app/src/main/java/xendroid/compose/gamepad/PadProfiles.kt @@ -0,0 +1,43 @@ +package xendroid.compose.gamepad + +import android.content.Context +import xendroid.compose.settings.ConfigStore + +/** Which profile each controller plays as, keyed by its stable descriptor. + * + * SharedPreferences for the same reason as [PadSlots]: pads attach from + * onCreate, before a coroutine could have loaded anything. */ +object PadProfiles { + + private const val PREFS = "pad_profiles" + + private fun prefs(context: Context) = + context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + /** Whether to ask who is playing when more than one controller is connected. */ + fun askOnStart(context: Context): Boolean { + val handle = ConfigStore(context).openLiveSnapshot() + return try { + handle.getBool("HID", "ask_players_on_start", true) + } finally { + handle.closeString() + } + } + + fun choiceFor(context: Context, descriptor: String): String? = + prefs(context).getString(key(descriptor), null)?.takeIf { it.isNotBlank() } + + fun setChoice(context: Context, descriptor: String, xuid: String) { + prefs(context).edit().putString(key(descriptor), xuid.uppercase()).apply() + } + + fun clearChoice(context: Context, descriptor: String) { + prefs(context).edit().remove(key(descriptor)).apply() + } + + /** The connected controller playing as [xuid], if any. */ + fun padFor(context: Context, xuid: String): GamepadDevice? = + connectedGamepads().firstOrNull { choiceFor(context, it.descriptor).equals(xuid, true) } + + private fun key(descriptor: String) = "choice_$descriptor" +} diff --git a/app/src/main/java/xendroid/compose/gamepad/PadSlots.kt b/app/src/main/java/xendroid/compose/gamepad/PadSlots.kt new file mode 100644 index 0000000000..d6f3bc89c6 --- /dev/null +++ b/app/src/main/java/xendroid/compose/gamepad/PadSlots.kt @@ -0,0 +1,38 @@ +package xendroid.compose.gamepad + +import android.content.Context +import android.preference.PreferenceManager + +/** The player slot a controller should take, keyed by its stable descriptor. + * Applied when the pad attaches rather than rebound afterwards, so it survives + * reconnects and boot order. + * + * SharedPreferences, not the DataStore the key mappings use: ControllerRegistry + * attaches pads from onCreate, before any coroutine could have loaded them. */ +object PadSlots { + const val AUTO = -1 + + private fun key(descriptor: String) = "pad_slot_$descriptor" + + fun preferredSlot(context: Context, descriptor: String): Int = + PreferenceManager.getDefaultSharedPreferences(context) + .getInt(key(descriptor), AUTO) + + fun setPreferredSlot(context: Context, descriptor: String, slot: Int) { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + @Suppress("DEPRECATION") + val editor = prefs.edit() + editor.apply { + if (slot == AUTO) { + remove(key(descriptor)) + } else { + // A slot holds one pad: drop it from whoever had it. + for ((k, v) in prefs.all) { + if (k.startsWith("pad_slot_") && v == slot) remove(k) + } + putInt(key(descriptor), slot) + } + } + editor.apply() + } +} diff --git a/app/src/main/java/xendroid/compose/gamepad/PadWatcher.kt b/app/src/main/java/xendroid/compose/gamepad/PadWatcher.kt new file mode 100644 index 0000000000..315d184141 --- /dev/null +++ b/app/src/main/java/xendroid/compose/gamepad/PadWatcher.kt @@ -0,0 +1,31 @@ +package xendroid.compose.gamepad + +import android.content.Context +import android.hardware.input.InputManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext + +/** The connected controllers, kept current as they come and go. */ +@Composable +fun rememberConnectedPads(): List { + val context = LocalContext.current + var pads by remember { mutableStateOf(connectedGamepads()) } + DisposableEffect(context) { + val manager = context.getSystemService(Context.INPUT_SERVICE) as? InputManager + val listener = object : InputManager.InputDeviceListener { + // Rebuilt rather than edited by id: a pad can be reported before its + // sources are readable. + override fun onInputDeviceAdded(deviceId: Int) { pads = connectedGamepads() } + override fun onInputDeviceRemoved(deviceId: Int) { pads = connectedGamepads() } + override fun onInputDeviceChanged(deviceId: Int) { pads = connectedGamepads() } + } + manager?.registerInputDeviceListener(listener, null) + onDispose { manager?.unregisterInputDeviceListener(listener) } + } + return pads +} diff --git a/app/src/main/java/xendroid/compose/gamepad/PlayerSetup.kt b/app/src/main/java/xendroid/compose/gamepad/PlayerSetup.kt new file mode 100644 index 0000000000..bb0dbc6da0 --- /dev/null +++ b/app/src/main/java/xendroid/compose/gamepad/PlayerSetup.kt @@ -0,0 +1,208 @@ +package xendroid.compose.gamepad + +import android.content.Context +import xendroid.compose.core.ContentPaths +import xendroid.compose.core.EmulatorRuntime +import kotlinx.coroutines.flow.MutableStateFlow +import xendroid.compose.settings.ConfigStore +import xendroid.compose.settings.Setting +import xendroid.compose.settings.SettingsSchema + +/** A player slot's controller and the profile it plays as. */ +data class PlayerAssignment(val descriptor: String?, val xuid: String) + +/** Who plays as whom, settled before a title launches. Slots are written as the + * logged-in profile cvars, so the kernel signs them in as the title boots. */ +object PlayerSetup { + + const val MAX_PLAYERS = 4 + + /** Raised when the slots are rewritten: the start-up dialog can land while the + * profile list is already open. */ + val revision = MutableStateFlow(0) + private const val PREFS = "pad_profiles" + private const val MAIN_PROFILE = "main_profile_xuid" + private const val MAIN_PAD = "main_pad_descriptor" + + fun slotKey(slot: Int) = "logged_profile_slot_${slot}_xuid" + + /** Pads in the order they would attach: pinned pads take their slot, the rest + * fill what is left in enumeration order. */ + fun orderedPads(context: Context): List { + val pads = connectedGamepads().take(MAX_PLAYERS) + val slots = arrayOfNulls(MAX_PLAYERS) + val loose = ArrayList() + for (pad in pads) { + val pinned = PadSlots.preferredSlot(context, pad.descriptor) + if (pinned in 0 until MAX_PLAYERS && slots[pinned] == null) { + slots[pinned] = pad + } else { + loose.add(pad) + } + } + val out = ArrayList(pads.size) + var next = 0 + for (slot in 0 until MAX_PLAYERS) { + val pad = slots[slot] ?: loose.getOrNull(next)?.also { next++ } + if (pad != null) out.add(pad) + } + return out + } + + /** The main profile goes to the pad that owns it, or to the first pad when + * that one is absent - taking it over, which leaves the stand-in's own profile + * signed out. Every other pad keeps the profile it was last set to. */ + fun defaults(context: Context): List { + val pads = orderedPads(context) + if (pads.isEmpty()) return emptyList() + val main = mainProfile(context) + val holder = pads.firstOrNull { it.descriptor == mainPad(context) } ?: pads.first() + val known = profiles().map { it.first.uppercase() } + val rows = pads.map { pad -> + // A pairing whose profile was deleted counts as none, so the pad picks + // up a live one below. + val xuid = if (main.isNotEmpty() && pad === holder) main + else PadProfiles.choiceFor(context, pad.descriptor)?.uppercase() + ?.takeIf { it in known }.orEmpty() + PlayerAssignment(pad.descriptor, xuid) + }.toMutableList() + // An unpaired pad takes a profile no other player is on, so the dialog + // opens ready to confirm. + val used = rows.map { it.xuid.uppercase() }.filter { it.isNotEmpty() }.toMutableSet() + val spare = known.filter { it !in used }.toMutableList() + rows.forEachIndexed { i, row -> + if (row.xuid.isEmpty() && spare.isNotEmpty()) { + val xuid = spare.removeAt(0) + used.add(xuid) + rows[i] = row.copy(xuid = xuid) + } + } + return rows + } + + /** Fewer than two pads: the main profile plays and nothing else is signed in. */ + fun applySolo(context: Context) { + val main = mainProfile(context) + if (main.isEmpty()) return + val handle = ConfigStore(context).openLive() + try { + handle.putString("Profiles", slotKey(0), main) + for (slot in 1 until MAX_PLAYERS) handle.putString("Profiles", slotKey(slot), "") + } finally { + handle.closeFile() + } + revision.value += 1 + } + + /** Remembered on the first setup: writing the slots later would otherwise make + * a generated profile look like the account the user plays as. */ + fun mainProfile(context: Context): String { + val prefs = prefs(context) + prefs.getString(MAIN_PROFILE, null)?.takeIf { it.isNotBlank() }?.let { return it } + val current = readSlot(context, 0).ifBlank { firstProfileXuid() } + if (current.isNotBlank()) { + prefs.edit().putString(MAIN_PROFILE, current.uppercase()).apply() + } + return current.uppercase() + } + + fun mainPad(context: Context): String? = + prefs(context).getString(MAIN_PAD, null)?.takeIf { it.isNotBlank() } + + private fun prefs(context: Context) = context.applicationContext + .getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun apply(context: Context, rows: List) { + val main = mainProfile(context) + val remembered = mainPad(context) + val connected = orderedPads(context).map { it.descriptor } + rows.forEachIndexed { slot, row -> + val descriptor = row.descriptor ?: return@forEachIndexed + PadSlots.setPreferredSlot(context, descriptor, slot) + // Tracked on its own, so a pad standing in for an absent one does not + // inherit the account for good. + if (row.xuid.equals(main, true)) { + if (remembered == null || remembered in connected) { + prefs(context).edit().putString(MAIN_PAD, descriptor).apply() + } + } else { + PadProfiles.setChoice(context, descriptor, row.xuid) + } + } + val handle = ConfigStore(context).openLive() + try { + for (slot in 0 until MAX_PLAYERS) { + handle.putString("Profiles", slotKey(slot), + rows.getOrNull(slot)?.xuid?.uppercase() ?: "") + } + } finally { + handle.closeFile() + } + revision.value += 1 + } + + /** Covers the shortfall only: with more pads than profiles some players would + * have nobody to sign in as. Nothing is created for a particular pad. */ + fun ensureEnoughProfiles(context: Context): Int { + val emu = EmulatorRuntime.emulator ?: return 0 + val wanted = orderedPads(context).size + if (wanted < 2) return 0 + val root = ContentPaths.contentRoot().absolutePath + val existing = profiles() + // Count profiles, not names: two may share a gamertag, and a name set would + // collapse them and under-count. + var have = existing.size + val taken = existing.map { it.second.lowercase() }.toMutableSet() + var created = 0 + // Player 1 is the account the user already has. + var next = 2 + while (have < wanted) { + while ("player $next" in taken) next++ + val tag = "Player $next" + runCatching { + emu.create_profile(root, tag, defaultLanguage(), defaultCountry()) + }.getOrNull() ?: break + taken.add(tag.lowercase()) + have++ + created++ + } + if (created > 0) revision.value += 1 + return created + } + + private fun defaultLanguage() = + (SettingsSchema.byKey["Console|user_language"] as? Setting.ListChoice) + ?.default?.toIntOrNull() ?: 1 + + private fun defaultCountry() = + (SettingsSchema.byKey["Console|user_country"] as? Setting.ListChoice) + ?.default?.toIntOrNull() ?: 103 + + fun profiles(): List> { + val emu = EmulatorRuntime.emulator ?: return emptyList() + val root = ContentPaths.contentRoot().absolutePath + return runCatching { emu.list_profiles(root) }.getOrNull() + ?.map { it.xuid to (it.gamertag ?: it.xuid) } ?: emptyList() + } + + private fun firstProfileXuid() = profiles().firstOrNull()?.first ?: "" + + /** The profile in each player slot, blank where nobody is signed in. */ + fun slotXuids(context: Context): List { + val handle = ConfigStore(context).openLiveSnapshot() + return try { + (0 until MAX_PLAYERS).map { handle.getString("Profiles", slotKey(it)) ?: "" } + } finally { + handle.closeString() + } + } + + private fun readSlot(context: Context, slot: Int): String { + val handle = ConfigStore(context).openLiveSnapshot() + return try { + handle.getString("Profiles", slotKey(slot)) ?: "" + } finally { + handle.closeString() + } + } +} diff --git a/app/src/main/java/xendroid/compose/gamepad/RumbleDriver.kt b/app/src/main/java/xendroid/compose/gamepad/RumbleDriver.kt new file mode 100644 index 0000000000..550231215f --- /dev/null +++ b/app/src/main/java/xendroid/compose/gamepad/RumbleDriver.kt @@ -0,0 +1,123 @@ +package xendroid.compose.gamepad + +import android.content.Context +import android.media.AudioAttributes +import android.os.Build +import android.os.VibrationAttributes +import android.os.SystemClock +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.util.Log +import android.view.InputDevice + +/** + * Plays the motor speeds the guest asked for on the pad that owns them. + * + * The guest sets a level and leaves it, so effects are issued as a repeating + * waveform and cancelled on zero rather than re-sent per poll. Main thread only. + */ +class RumbleDriver(context: Context) { + + private val appContext = context.applicationContext + private val systemVibrator: Vibrator? = resolveSystemVibrator() + private val lastAmplitude = HashMap() + private val lastIssuedAt = HashMap() + + /** + * @param state left/right pairs indexed by device slot, as returned by native + * @param vibratorFor device slot -> vibrator, null for slots with no motor + */ + fun apply(state: IntArray, vibratorFor: (Int) -> Vibrator?) { + val now = SystemClock.uptimeMillis() + for (slot in 0 until state.size / 2) { + val target = amplitudeFor(maxOf(state[slot * 2], state[slot * 2 + 1])) + val current = lastAmplitude[slot] ?: 0 + if (target == current) continue + // Issuing an effect replaces the one playing, stopping the motor and + // spinning it up again from rest. Start and stop must be immediate, but + // a level change between them is held back: at poll rate the restarts + // alone keep the motor from ever reaching full strength. + if (target != 0 && current != 0 && + now - (lastIssuedAt[slot] ?: 0L) < REISSUE_MIN_MS) continue + val vibrator = vibratorFor(slot) + Log.i(TAG, "slot=$slot guest=${state[slot * 2]}/${state[slot * 2 + 1]} " + + "amplitude=$target vibrator=${vibrator != null}") + lastAmplitude[slot] = target + lastIssuedAt[slot] = now + if (vibrator == null) continue + runCatching { + if (target == 0) vibrator.cancel() else play(vibrator, target) + } + } + } + + /** Quantized so small fluctuations do not restart the motor, and floored + * because below roughly a fifth of full scale an LRA barely moves. */ + private fun amplitudeFor(strongest: Int): Int { + if (strongest <= 0) return 0 + val step = ((strongest.toLong() * STEPS + 32767) / 65535).toInt().coerceIn(1, STEPS) + return MIN_AMPLITUDE + (255 - MIN_AMPLITUDE) * (step - 1) / (STEPS - 1) + } + + // Tagged as game usage so the system touch-feedback setting does not filter it. + private fun play(vibrator: Vibrator, amplitude: Int) { + val effect = VibrationEffect.createWaveform( + longArrayOf(0, PULSE_MS), + intArrayOf(0, amplitude), + 1, // repeat the on-phase until cancelled + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + vibrator.vibrate(effect, VibrationAttributes.createForUsage(VibrationAttributes.USAGE_MEDIA)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(effect, gameAudioAttributes) + } + } + + private val gameAudioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + + fun stopAll(vibratorFor: (Int) -> Vibrator?) { + lastAmplitude.keys.toList().forEach { slot -> + runCatching { vibratorFor(slot)?.cancel() } + } + lastAmplitude.clear() + lastIssuedAt.clear() + } + + /** Falls back to the console's own motor for the on-screen overlay, and when + * [fallbackToSystem] for a motorless pad: a handheld's built-in controls + * rumble through the system vibrator. */ + fun vibratorForDevice(deviceId: Int?, fallbackToSystem: Boolean = false): Vibrator? { + if (deviceId == null) return systemVibrator + val device = InputDevice.getDevice(deviceId) ?: return null + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + device.vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + device.vibrator + } + return vibrator?.takeIf { it.hasVibrator() } + ?: systemVibrator.takeIf { fallbackToSystem } + } + + private fun resolveSystemVibrator(): Vibrator? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + (appContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager) + ?.defaultVibrator + } else { + @Suppress("DEPRECATION") + appContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + }?.takeIf { it.hasVibrator() } + + private companion object { + const val PULSE_MS = 60_000L + const val REISSUE_MIN_MS = 100L + const val STEPS = 8 + const val MIN_AMPLITUDE = 48 + const val TAG = "XenDroidRumble" + } +} diff --git a/app/src/main/java/xendroid/compose/settings/SettingDescriptions.kt b/app/src/main/java/xendroid/compose/settings/SettingDescriptions.kt index f91e206aba..922f5dc6eb 100644 --- a/app/src/main/java/xendroid/compose/settings/SettingDescriptions.kt +++ b/app/src/main/java/xendroid/compose/settings/SettingDescriptions.kt @@ -21,6 +21,8 @@ object SettingDescriptions { "break_on_start" to "Breaks into the debugger as soon as the emulator starts", "break_on_unimplemented_instructions" to "Breaks into the debugger (or crashes) when an unimplemented PowerPC instruction is hit", "apu_pump_topup" to "Keep the audio buffer full by submitting ahead; turn off if a game's audio distorts", + "vibration" to "Play controller rumble when a game asks for it", + "vibration_level" to "How strong rumble is, as a percentage of what the game asks for", "clear_memory_page_state" to "Refresh memory page state so GPU-written data is visible; off is slightly faster but may break rendering", "clock_no_scaling" to "Bypasses guest clock scaling and pulls time straight from the host; ignores time scalar", "clock_source_raw" to "Uses the raw CPU counter (CNTVCT) as the time source instead of OS clock calls", @@ -62,6 +64,8 @@ object SettingDescriptions { "kernel_cert_monitor" to "Reports the certification monitor as present to the game (debug/testing feature)", "kernel_debug_monitor" to "Reports a debug monitor (remote debugger) as attached to the game", "kernel_display_gamma_type" to "Display gamma curve reported to games; BT.709 best matches a 360 on an HDTV", + "ask_players_on_start" to "Asks which profile each controller plays as when more " + + "than one controller is connected", "network_enabled" to "Lets games open network sockets. They cannot reach a real peer, but " + "some games crash when socket creation fails. Turn off only for a game that hangs online", "kernel_pix" to "Exposes the PIX GPU profiler interface to the game for debug captures", diff --git a/app/src/main/java/xendroid/compose/settings/SettingsSchema.kt b/app/src/main/java/xendroid/compose/settings/SettingsSchema.kt index 3035d30436..9ff0e2c0d2 100644 --- a/app/src/main/java/xendroid/compose/settings/SettingsSchema.kt +++ b/app/src/main/java/xendroid/compose/settings/SettingsSchema.kt @@ -99,6 +99,9 @@ object SettingsSchema { SettingsCategory("Controller", listOf( b("HID", "show_touch_overlay", "Show on-screen controller", true), + b("HID", "ask_players_on_start", "Ask who is playing on start", true), + b("HID", "vibration", "Vibration", true), + i("HID", "vibration_level", "Vibration intensity", 100, 0, 100), )), SettingsCategory("HID", listOf( diff --git a/app/src/main/java/xendroid/compose/ui/keymap/KeymapScreen.kt b/app/src/main/java/xendroid/compose/ui/keymap/KeymapScreen.kt index f8f7a41323..3f57485c51 100644 --- a/app/src/main/java/xendroid/compose/ui/keymap/KeymapScreen.kt +++ b/app/src/main/java/xendroid/compose/ui/keymap/KeymapScreen.kt @@ -15,13 +15,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.* +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import xendroid.compose.gamepad.PadSlots @OptIn(ExperimentalMaterial3Api::class) @Composable fun KeymapScreen(vm: KeymapViewModel, onBack: () -> Unit) { val state by vm.state.collectAsStateWithLifecycle() var capturing by remember { mutableStateOf(null) } + var pickerOpen by remember { mutableStateOf(false) } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) vm.refreshDevices() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } Scaffold( topBar = { @@ -39,6 +54,109 @@ fun KeymapScreen(vm: KeymapViewModel, onBack: () -> Unit) { } ) { padding -> LazyColumn(Modifier.fillMaxSize().padding(padding)) { + item { + val label = state.selected?.name ?: "All controllers" + ListItem( + headlineContent = { Text(label) }, + overlineContent = { Text("Mapping for") }, + supportingContent = { + Text( + when { + state.selected == null -> + "Used by every controller without its own mapping" + state.selectedHasOwnMapping -> "This controller has its own mapping" + else -> "Inheriting the shared mapping; editing a button makes it its own" + } + ) + }, + trailingContent = { + Box { + TextButton(onClick = { pickerOpen = true }) { Text("Change") } + DropdownMenu( + expanded = pickerOpen, + onDismissRequest = { pickerOpen = false }, + ) { + DropdownMenuItem( + text = { Text("All controllers") }, + onClick = { + pickerOpen = false + vm.onSelectDevice(null) + }, + ) + state.devices.forEach { device -> + DropdownMenuItem( + text = { Text(device.name) }, + onClick = { + pickerOpen = false + vm.onSelectDevice(device.descriptor) + }, + ) + } + if (state.devices.isEmpty()) { + DropdownMenuItem( + text = { Text("No controllers connected") }, + enabled = false, + onClick = {}, + ) + } + } + } + }, + ) + if (state.selected != null) { + var playerMenu by remember(state.selected) { mutableStateOf(false) } + ListItem( + headlineContent = { Text("Player") }, + supportingContent = { + Text( + if (state.selectedPlayerSlot == PadSlots.AUTO) { + "Assigned automatically in connection order" + } else { + "Applied the next time a game starts" + } + ) + }, + trailingContent = { + Box { + TextButton(onClick = { playerMenu = true }) { + Text( + if (state.selectedPlayerSlot == PadSlots.AUTO) "Auto" + else "Player ${state.selectedPlayerSlot + 1}" + ) + } + DropdownMenu( + expanded = playerMenu, + onDismissRequest = { playerMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Auto") }, + onClick = { + playerMenu = false + vm.onSelectPlayerSlot(PadSlots.AUTO) + }, + ) + for (slot in 0 until 4) { + DropdownMenuItem( + text = { Text("Player ${slot + 1}") }, + onClick = { + playerMenu = false + vm.onSelectPlayerSlot(slot) + }, + ) + } + } + } + }, + ) + } + if (state.selectedHasOwnMapping) { + TextButton( + onClick = { vm.onUseSharedMapping() }, + modifier = Modifier.padding(horizontal = 16.dp), + ) { Text("Use the shared mapping instead") } + } + HorizontalDivider() + } items(state.rows, key = { it.button.index }) { row -> ListItem( headlineContent = { Text(row.button.label) }, diff --git a/app/src/main/java/xendroid/compose/ui/keymap/KeymapViewModel.kt b/app/src/main/java/xendroid/compose/ui/keymap/KeymapViewModel.kt index e5e0ba3b88..badb6f4d5c 100644 --- a/app/src/main/java/xendroid/compose/ui/keymap/KeymapViewModel.kt +++ b/app/src/main/java/xendroid/compose/ui/keymap/KeymapViewModel.kt @@ -1,39 +1,98 @@ package xendroid.compose.ui.keymap +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import xendroid.compose.data.GameButton import xendroid.compose.data.GameButtons import xendroid.compose.data.KeymapStore +import xendroid.compose.gamepad.GamepadDevice +import xendroid.compose.gamepad.PadSlots +import xendroid.compose.gamepad.connectedGamepads +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.ExperimentalCoroutinesApi data class KeymapRow(val button: GameButton, val boundKey: Int) // boundKey 0 = cleared data class KeymapUiState( val rows: List = emptyList(), val vibrate: Boolean = false, + val devices: List = emptyList(), + val selected: GamepadDevice? = null, + val selectedHasOwnMapping: Boolean = false, + val selectedPlayerSlot: Int = PadSlots.AUTO, ) -class KeymapViewModel(private val store: KeymapStore) : ViewModel() { +@OptIn(ExperimentalCoroutinesApi::class) +class KeymapViewModel( + private val appContext: Context, + private val store: KeymapStore, +) : ViewModel() { + + private val devices = MutableStateFlow(connectedGamepads()) + private val selected = MutableStateFlow(null) + // Bumped on write so the state flow re-reads the (synchronous) pad slots. + private val padSlotEpoch = MutableStateFlow(0) val state: StateFlow = - combine(store.bindings, store.vibrateEnabled) { bindings, vibrate -> - KeymapUiState( - rows = GameButtons.ALL.map { b -> KeymapRow(b, bindings[b.index] ?: b.defaultAndroidKey) }, - vibrate = vibrate, - ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), KeymapUiState()) + combine(devices, selected, padSlotEpoch) { devices, selected, _ -> devices to selected } + .flatMapLatest { (devices, selectedDescriptor) -> + val device = devices.firstOrNull { it.descriptor == selectedDescriptor } + // A selected pad that has since disconnected falls back to shared. + val target = device?.descriptor + combine( + store.bindingsFor(target ?: ""), + store.vibrateEnabled, + if (target != null) store.hasOwnMapping(target) else flowOf(false), + ) { bindings, vibrate, own -> + KeymapUiState( + rows = GameButtons.ALL.map { b -> + KeymapRow(b, bindings[b.index] ?: b.defaultAndroidKey) + }, + vibrate = vibrate, + devices = devices, + selected = device, + selectedHasOwnMapping = target != null && own, + selectedPlayerSlot = target?.let { PadSlots.preferredSlot(appContext, it) } + ?: PadSlots.AUTO, + ) + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), KeymapUiState()) + + fun refreshDevices() { + devices.value = connectedGamepads() + } + + fun onSelectDevice(descriptor: String?) { + selected.value = descriptor + } fun onKeyCaptured(index: Int, androidKeyCode: Int) = - viewModelScope.launch { store.setBinding(index, androidKeyCode) } + viewModelScope.launch { store.setBinding(selected.value, index, androidKeyCode) } + + fun onClear(index: Int) = viewModelScope.launch { store.clearBinding(selected.value, index) } + + fun onResetDefaults() = viewModelScope.launch { store.resetToDefaults(selected.value) } - fun onClear(index: Int) = viewModelScope.launch { store.clearBinding(index) } + /** Applied when the pad next attaches, so it takes effect on the next launch. */ + fun onSelectPlayerSlot(slot: Int) { + val device = selected.value ?: return + PadSlots.setPreferredSlot(appContext, device, slot) + padSlotEpoch.value += 1 + } - fun onResetDefaults() = viewModelScope.launch { store.resetToDefaults() } + fun onUseSharedMapping() { + val device = selected.value ?: return + viewModelScope.launch { store.useSharedMapping(device) } + } fun onVibrateChanged(enabled: Boolean) = viewModelScope.launch { store.setVibrate(enabled) } } diff --git a/app/src/main/java/xendroid/compose/ui/library/GameLibraryScreen.kt b/app/src/main/java/xendroid/compose/ui/library/GameLibraryScreen.kt index 0b99dbd022..834d288001 100644 --- a/app/src/main/java/xendroid/compose/ui/library/GameLibraryScreen.kt +++ b/app/src/main/java/xendroid/compose/ui/library/GameLibraryScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* +import xendroid.compose.ui.profile.SignedInBadge import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -113,7 +114,7 @@ fun GameLibraryScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Library") }, + title = { SignedInBadge(onClick = onOpenProfiles) }, actions = { IconButton(onClick = onOpenSettings) { Icon(Icons.Default.Settings, contentDescription = "Settings") @@ -135,10 +136,6 @@ fun GameLibraryScreen( text = { Text("Install content") }, onClick = { menuOpen = false; onOpenInstallContent() }, ) - DropdownMenuItem( - text = { Text("Profiles") }, - onClick = { menuOpen = false; onOpenProfiles() }, - ) DropdownMenuItem( text = { Text("Key mapping") }, onClick = { menuOpen = false; onOpenKeymap() }, diff --git a/app/src/main/java/xendroid/compose/ui/players/PlayerSetupDialog.kt b/app/src/main/java/xendroid/compose/ui/players/PlayerSetupDialog.kt new file mode 100644 index 0000000000..6489501f77 --- /dev/null +++ b/app/src/main/java/xendroid/compose/ui/players/PlayerSetupDialog.kt @@ -0,0 +1,158 @@ +package xendroid.compose.ui.players + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import xendroid.compose.gamepad.GamepadDevice +import xendroid.compose.gamepad.PlayerAssignment + +/** + * Who plays as whom, shown at start-up while more than one controller is + * connected. It opens already filled in, so confirming is the whole interaction. + */ +@Composable +fun PlayerSetupDialog( + pads: List, + profiles: List>, + initial: List, + onConfirm: (List) -> Unit, + onDismiss: () -> Unit, +) { + val rows = remember { mutableStateListOf().apply { addAll(initial) } } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Players") }, + text = { + Column { + Text( + "${pads.size} controllers connected. Each player signs in with " + + "their own profile.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(8.dp)) + // Four players do not fit the dialog on a handheld in landscape. + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + rows.forEachIndexed { slot, row -> + PlayerCard( + slot = slot, + row = row, + pads = pads, + profiles = profiles, + onPad = { descriptor -> + // A pad drives one player: hand it over rather than + // leaving it on two rows. + rows.forEachIndexed { i, other -> + if (i != slot && other.descriptor == descriptor) { + rows[i] = other.copy(descriptor = row.descriptor) + } + } + rows[slot] = rows[slot].copy(descriptor = descriptor) + }, + onProfile = { rows[slot] = rows[slot].copy(xuid = it) }, + ) + } + } + } + }, + confirmButton = { TextButton(onClick = { onConfirm(rows.toList()) }) { Text("Confirm") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable +private fun PlayerCard( + slot: Int, + row: PlayerAssignment, + pads: List, + profiles: List>, + onPad: (String?) -> Unit, + onProfile: (String) -> Unit, +) { + // Fixed height, and single-line labels to keep it: pad names run long. + Card(Modifier.fillMaxWidth().height(CARD_HEIGHT)) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + Text("Player ${slot + 1}", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + val profileName = profiles.firstOrNull { it.first.equals(row.xuid, true) }?.second + Picker( + modifier = Modifier.weight(1f), + label = profileName ?: "No profile", + placeholder = profileName == null, + options = profiles, + onPick = { onProfile(it ?: "") }, + ) + Spacer(Modifier.width(8.dp)) + val padName = pads.firstOrNull { it.descriptor == row.descriptor }?.name + Picker( + modifier = Modifier.weight(1f), + label = padName ?: "No controller", + placeholder = padName == null, + options = pads.map { it.descriptor to it.name }, + onPick = onPad, + ) + } + } + } +} + +private val CARD_HEIGHT = 92.dp + +@Composable +private fun Picker( + modifier: Modifier, + label: String, + placeholder: Boolean, + options: List>, + onPick: (String?) -> Unit, +) { + var open by remember { mutableStateOf(false) } + Box(modifier) { + // Outlined and carated so it reads as a field to tap. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(4.dp)) + .clickable { open = true } + .padding(start = 8.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), + ) { + Text( + label, + style = MaterialTheme.typography.bodyMedium, + color = if (placeholder) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + options.forEach { (value, text) -> + DropdownMenuItem( + text = { Text(text) }, + onClick = { onPick(value); open = false }, + ) + } + } + } +} diff --git a/app/src/main/java/xendroid/compose/ui/profile/ProfileManagerViewModel.kt b/app/src/main/java/xendroid/compose/ui/profile/ProfileManagerViewModel.kt index 6dc3ec11c5..baf338362e 100644 --- a/app/src/main/java/xendroid/compose/ui/profile/ProfileManagerViewModel.kt +++ b/app/src/main/java/xendroid/compose/ui/profile/ProfileManagerViewModel.kt @@ -13,6 +13,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import xendroid.compose.core.ContentPaths +import xendroid.compose.gamepad.GamepadDevice +import xendroid.compose.gamepad.PadProfiles +import xendroid.compose.gamepad.PlayerSetup +import xendroid.compose.gamepad.connectedGamepads import xendroid.compose.core.EmulatorRuntime import xendroid.compose.core.Gamertag import xendroid.compose.core.ProfilePaths @@ -30,8 +34,21 @@ class ProfileManagerViewModel( val language: Int, val country: Int, val hasAvatar: Boolean, - val isActive: Boolean, - ) + /** Slot 0 is player 1. */ + val slot: Int?, + /** Connected controller playing as this profile, null for none. */ + val controller: GamepadDevice? = null, + /** True when [controller] was attached by hand rather than inferred from + * the player slot. Only an attached one can be detached. */ + val controllerAttached: Boolean = false, + ) { + val isActive: Boolean get() = slot != null + } + + companion object { + /** Xenia signs in at most XUserMaxUserCount profiles, one per guest slot. */ + const val SLOT_COUNT = 4 + } sealed interface ListState { data object Loading : ListState @@ -64,15 +81,20 @@ class ProfileManagerViewModel( ?: return@withContext ListState.Error("Emulator not loaded.") val root = ContentPaths.contentRoot().absolutePath try { - val active = activeXuid() - val profiles = emu.list_profiles(root)?.map { + val slots = slotXuids() + val padsBySlot = PlayerSetup.orderedPads(appContext) + val profiles = emu.list_profiles(root)?.map { info -> + val slot = slots.indexOfFirst { it.equals(info.xuid, ignoreCase = true) } + .takeIf { it >= 0 } ProfileEntry( - xuid = it.xuid, - gamertag = it.gamertag ?: "", - language = it.language, - country = it.country, - hasAvatar = it.hasAvatar, - isActive = it.xuid.equals(active, ignoreCase = true), + xuid = info.xuid, + gamertag = info.gamertag ?: "", + language = info.language, + country = info.country, + hasAvatar = info.hasAvatar, + slot = slot, + controller = controllerFor(info.xuid, slot, padsBySlot), + controllerAttached = PadProfiles.padFor(appContext, info.xuid) != null, ) }?.sortedBy { it.gamertag.lowercase() } ?: return@withContext ListState.Error("Couldn't read profiles.") @@ -128,9 +150,20 @@ class ProfileManagerViewModel( } } - fun setActive(xuid: String) = viewModelScope.launch { - withContext(Dispatchers.IO) { writeActiveXuid(xuid.uppercase()) } - _opState.value = OpState.Done("Active profile set. Applies on next game launch.") + /** A blank xuid empties the slot. Takes effect at the next game start: xenia + * signs profiles in while the emulator boots. */ + fun assignSlot(slot: Int, xuid: String) = viewModelScope.launch { + withContext(Dispatchers.IO) { + val slots = slotXuids().toMutableList() + val id = xuid.uppercase() + if (id.isNotEmpty()) { + for (i in slots.indices) { + if (slots[i].equals(id, ignoreCase = true)) slots[i] = "" + } + } + slots[slot] = id + writeSlots(slots) + } refresh() } @@ -141,7 +174,12 @@ class ProfileManagerViewModel( if (!ProfilePaths.XUID_REGEX.matches(id)) return@withContext false val dir = File(ContentPaths.contentRoot(), id) val removed = dir.deleteRecursively() - if (activeXuid().equals(id, ignoreCase = true)) writeActiveXuid("") + val slots = slotXuids().toMutableList() + var changed = false + for (i in slots.indices) { + if (slots[i].equals(id, ignoreCase = true)) { slots[i] = ""; changed = true } + } + if (changed) writeSlots(slots) removed } if (ok) { @@ -152,19 +190,55 @@ class ProfileManagerViewModel( } } - private fun activeXuid(): String { + /** The pad playing as this profile: the one attached to it, else whichever + * pad drives the player slot it is signed in to - the signed-in profile has a + * controller whether or not anyone attached one by hand. A pad already + * attached to some other profile never stands in. */ + private fun controllerFor( + xuid: String, + slot: Int?, + padsBySlot: List, + ): GamepadDevice? { + PadProfiles.padFor(appContext, xuid)?.let { return it } + val pad = slot?.let { padsBySlot.getOrNull(it) } ?: return null + return pad.takeIf { PadProfiles.choiceFor(appContext, it.descriptor) == null } + } + + /** One controller per profile and one profile per controller, so attaching + * takes the pad off whatever it was playing as. */ + fun attachController(xuid: String, descriptor: String?) = viewModelScope.launch { + withContext(Dispatchers.IO) { + connectedGamepads().forEach { pad -> + if (PadProfiles.choiceFor(appContext, pad.descriptor).equals(xuid, true)) { + PadProfiles.clearChoice(appContext, pad.descriptor) + } + } + if (descriptor != null) PadProfiles.setChoice(appContext, descriptor, xuid) + } + refresh() + } + + fun connectedControllers(): List = connectedGamepads() + + private fun slotKey(slot: Int) = "logged_profile_slot_${slot}_xuid" + + private fun slotXuids(): List { val h = configStore.openLiveSnapshot() return try { - h.getString("Profiles", "logged_profile_slot_0_xuid") ?: "" + (0 until SLOT_COUNT).map { h.getString("Profiles", slotKey(it)) ?: "" } } finally { h.closeString() } } - private fun writeActiveXuid(xuid: String) { + /** Written as a whole set: a profile occupies at most one slot, so assigning + * it has to clear wherever it was. */ + private fun writeSlots(xuids: List) { val h = configStore.openLive() try { - h.putString("Profiles", "logged_profile_slot_0_xuid", xuid) + for (slot in 0 until SLOT_COUNT) { + h.putString("Profiles", slotKey(slot), xuids.getOrElse(slot) { "" }) + } } finally { h.closeFile() } diff --git a/app/src/main/java/xendroid/compose/ui/profile/ProfilesScreen.kt b/app/src/main/java/xendroid/compose/ui/profile/ProfilesScreen.kt index bcb647ec6a..ebae7d1f76 100644 --- a/app/src/main/java/xendroid/compose/ui/profile/ProfilesScreen.kt +++ b/app/src/main/java/xendroid/compose/ui/profile/ProfilesScreen.kt @@ -38,6 +38,9 @@ import xendroid.compose.settings.Setting import xendroid.compose.settings.SettingsSchema import xendroid.compose.ui.profile.ProfileManagerViewModel.ListState import xendroid.compose.ui.profile.ProfileManagerViewModel.OpState +import xendroid.compose.gamepad.GamepadDevice +import xendroid.compose.gamepad.PlayerSetup +import xendroid.compose.gamepad.rememberConnectedPads import xendroid.compose.ui.profile.ProfileManagerViewModel.ProfileEntry // Top-level vals, so a hard cast on a key whose toml section moved would throw @@ -66,6 +69,11 @@ fun ProfilesScreen( ) { val listState by vm.listState.collectAsStateWithLifecycle() val opState by vm.opState.collectAsStateWithLifecycle() + // A controller coming or going changes which pad drives a profile, and the + // start-up dialog can rewrite the slots while this screen is open. + val pads = rememberConnectedPads() + val assignments by PlayerSetup.revision.collectAsStateWithLifecycle() + LaunchedEffect(pads.joinToString(",") { it.descriptor }, assignments) { vm.refresh() } var editing by remember { mutableStateOf(Editing.None) } var deleteTarget by remember { mutableStateOf(null) } @@ -98,7 +106,9 @@ fun ProfilesScreen( } else { ProfileList( profiles = s.profiles, - onSelect = vm::setActive, + controllers = pads, + onAssign = vm::assignSlot, + onAttach = vm::attachController, onRename = { editing = Editing.Rename(it) }, onDelete = { deleteTarget = it }, ) @@ -169,22 +179,79 @@ fun ProfilesScreen( @Composable private fun ProfileList( profiles: List, - onSelect: (String) -> Unit, + controllers: List, + onAssign: (Int, String) -> Unit, + onAttach: (String, String?) -> Unit, onRename: (ProfileEntry) -> Unit, onDelete: (ProfileEntry) -> Unit, ) { LazyColumn(Modifier.fillMaxSize()) { items(profiles, key = { it.xuid }) { p -> + var slotMenu by remember(p.xuid) { mutableStateOf(false) } ListItem( leadingContent = { ProfileAvatar(p) }, headlineContent = { Text(p.gamertag.ifBlank { p.xuid }, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - supportingContent = if (p.isActive) ({ Text("Active") }) else null, + supportingContent = { + val slot = p.slot?.let { "Player ${it + 1}" } ?: "Not signed in" + Text(p.controller?.let { "$slot - ${it.name}" } ?: slot, + maxLines = 1, overflow = TextOverflow.Ellipsis) + }, trailingContent = { RowMenu(p, onRename, onDelete) }, - modifier = Modifier.clickable { onSelect(p.xuid) }, + modifier = Modifier.clickable { slotMenu = true }, ) + Box { + DropdownMenu(expanded = slotMenu, onDismissRequest = { slotMenu = false }) { + for (slot in 0 until ProfileManagerViewModel.SLOT_COUNT) { + DropdownMenuItem( + text = { Text("Sign in as player ${slot + 1}") }, + trailingIcon = if (p.slot == slot) ({ + Icon(Icons.Default.CheckCircle, contentDescription = null) + }) else null, + onClick = { + slotMenu = false + onAssign(slot, p.xuid) + }, + ) + } + if (p.slot != null) { + HorizontalDivider() + DropdownMenuItem( + text = { Text("Sign out") }, + onClick = { + slotMenu = false + onAssign(p.slot, "") + }, + ) + } + if (controllers.isNotEmpty()) { + HorizontalDivider() + for (pad in controllers) { + DropdownMenuItem( + text = { Text("Play with ${pad.name}") }, + trailingIcon = if (p.controller?.descriptor == pad.descriptor) ({ + Icon(Icons.Default.CheckCircle, contentDescription = null) + }) else null, + onClick = { + slotMenu = false + onAttach(p.xuid, pad.descriptor) + }, + ) + } + if (p.controllerAttached) { + DropdownMenuItem( + text = { Text("Detach controller") }, + onClick = { + slotMenu = false + onAttach(p.xuid, null) + }, + ) + } + } + } + } HorizontalDivider() } } diff --git a/app/src/main/java/xendroid/compose/ui/profile/SignedInBadge.kt b/app/src/main/java/xendroid/compose/ui/profile/SignedInBadge.kt new file mode 100644 index 0000000000..b1ab9e33df --- /dev/null +++ b/app/src/main/java/xendroid/compose/ui/profile/SignedInBadge.kt @@ -0,0 +1,109 @@ +package xendroid.compose.ui.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import xendroid.compose.core.ContentPaths +import xendroid.compose.core.EmulatorRuntime +import xendroid.compose.core.ProfilePaths +import xendroid.compose.gamepad.PlayerSetup +import xendroid.compose.gamepad.rememberConnectedPads + +/** Gamertag of the signed-in profile, or stacked avatars and a count when several + * are. Stands in for the library title and opens the profile manager. */ +@Composable +fun SignedInBadge(onClick: () -> Unit) { + val context = LocalContext.current + // Signing in happens on other screens, so this re-reads on slot and pad changes. + val revision by PlayerSetup.revision.collectAsStateWithLifecycle() + val pads = rememberConnectedPads() + var users by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(revision, pads.size) { + users = withContext(Dispatchers.IO) { signedInUsers(context) } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clip(MaterialTheme.shapes.small).clickable(onClick = onClick) + .padding(horizontal = 4.dp, vertical = 2.dp), + ) { + if (users.isEmpty()) { + Icon(Icons.Default.Person, contentDescription = null, Modifier.size(28.dp)) + Spacer(Modifier.width(8.dp)) + Text("Not signed in", maxLines = 1, overflow = TextOverflow.Ellipsis) + return@Row + } + // Overlapped so four players still read as one control. + Box { + users.forEachIndexed { i, user -> + Avatar(user, Modifier.padding(start = (i * AVATAR_STEP.value).dp)) + } + } + Spacer(Modifier.width(8.dp)) + if (users.size == 1) { + Text(users[0].gamertag, maxLines = 1, overflow = TextOverflow.Ellipsis) + } else { + Text("${users.size} players", maxLines = 1) + } + } +} + +@Composable +private fun Avatar(user: SignedInUser, modifier: Modifier) { + val context = LocalContext.current + val ring = Modifier.size(AVATAR_SIZE).clip(CircleShape) + .border(1.dp, MaterialTheme.colorScheme.surface, CircleShape) + if (user.hasAvatar) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(remember(user.xuid) { ProfilePaths.tile64Path(user.xuid) }).build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier.then(ring), + ) + } else { + Box( + modifier.then(ring).background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Person, contentDescription = null, Modifier.size(18.dp)) + } + } +} + +data class SignedInUser(val xuid: String, val gamertag: String, val hasAvatar: Boolean) + +/** The profiles in the player slots, in slot order. */ +private fun signedInUsers(context: android.content.Context): List { + val emu = EmulatorRuntime.emulator ?: return emptyList() + val root = ContentPaths.contentRoot().absolutePath + val profiles = runCatching { emu.list_profiles(root) }.getOrNull() ?: return emptyList() + return PlayerSetup.slotXuids(context) + .filter { it.isNotBlank() } + .distinctBy { it.uppercase() } + .mapNotNull { xuid -> + profiles.firstOrNull { it.xuid.equals(xuid, ignoreCase = true) } + } + .map { SignedInUser(it.xuid, it.gamertag ?: it.xuid, it.hasAvatar) } +} + +private val AVATAR_SIZE = 28.dp +private val AVATAR_STEP = 11.dp diff --git a/app/src/test/java/xendroid/compose/settings/SettingsSchemaTest.kt b/app/src/test/java/xendroid/compose/settings/SettingsSchemaTest.kt index 7d4fbd9102..5896633aea 100644 --- a/app/src/test/java/xendroid/compose/settings/SettingsSchemaTest.kt +++ b/app/src/test/java/xendroid/compose/settings/SettingsSchemaTest.kt @@ -13,20 +13,20 @@ class SettingsSchemaTest { private val all = SettingsSchema.allSettings - // 101 Bool + 12 IntRange + 21 ListChoice + 2 Action = 136. Display|host_present_from_non_ui_thread + // 103 Bool + 13 IntRange + 21 ListChoice + 2 Action = 139. Display|host_present_from_non_ui_thread // is intentionally absent (forced true natively; not a valid user choice). - @Test fun total_entry_count_is_136() { - assertEquals(136, all.size) + @Test fun total_entry_count_is_139() { + assertEquals(139, all.size) assertEquals( - 136, + 139, all.count { it is Setting.Bool } + all.count { it is Setting.IntRange } + all.count { it is Setting.ListChoice } + all.count { it is Setting.Action }, ) } @Test fun counts_by_type_match_verified_inventory() { - assertEquals(101, all.count { it is Setting.Bool }) - assertEquals(12, all.count { it is Setting.IntRange }) + assertEquals(103, all.count { it is Setting.Bool }) + assertEquals(13, all.count { it is Setting.IntRange }) assertEquals(21, all.count { it is Setting.ListChoice }) assertEquals(2, all.count { it is Setting.Action }) } diff --git a/emulator-core/src/main/cpp/emulator.cpp b/emulator-core/src/main/cpp/emulator.cpp index f46b895206..724787e59a 100644 --- a/emulator-core/src/main/cpp/emulator.cpp +++ b/emulator-core/src/main/cpp/emulator.cpp @@ -460,8 +460,69 @@ static void j_setup_surface(JNIEnv* env,jobject self,jobject surface){ } } -static void j_key_event(JNIEnv* env,jobject self,jint key_code,jboolean pressed,jint value){ - ae::key_event(key_code,pressed,value); +static void j_key_event(JNIEnv* env,jobject self,jint device_slot,jint key_code,jboolean pressed,jint value){ + ae::key_event(device_slot,key_code,pressed,value); +} + +static jint j_input_attach_device(JNIEnv* env,jobject self,jstring stable_id,jstring display_name,jint subtype,jint preferred_slot){ + const char* stable_id_chars=stable_id?env->GetStringUTFChars(stable_id,nullptr):nullptr; + const char* display_name_chars=display_name?env->GetStringUTFChars(display_name,nullptr):nullptr; + const jint slot=ae::input_attach_device(stable_id_chars,display_name_chars,subtype,preferred_slot); + if(stable_id_chars) env->ReleaseStringUTFChars(stable_id,stable_id_chars); + if(display_name_chars) env->ReleaseStringUTFChars(display_name,display_name_chars); + return slot; +} + +static void j_input_detach_device(JNIEnv* env,jobject self,jint device_slot){ + ae::input_detach_device(device_slot); +} + +static jboolean j_input_bind_slot(JNIEnv* env,jobject self,jint guest_slot,jint device_slot){ + return ae::input_bind_slot(guest_slot,device_slot)?JNI_TRUE:JNI_FALSE; +} + +static void j_input_unbind_slot(JNIEnv* env,jobject self,jint guest_slot){ + ae::input_unbind_slot(guest_slot); +} + +static jintArray j_input_vibration_state(JNIEnv* env,jobject self){ + const auto state=ae::input_vibration_state(); + jintArray out=env->NewIntArray(jsize(state.size())); + if(!out || state.empty()){ + return out; + } + std::vector values(state.begin(),state.end()); + env->SetIntArrayRegion(out,0,jsize(values.size()),values.data()); + return out; +} + +static jobjectArray j_input_list_devices(JNIEnv* env,jobject self){ + const auto devices=ae::input_list_devices(); + jclass cls=env->FindClass("xendroid/emulator/Emulator$InputDeviceInfo"); + if(!cls){ + return nullptr; + } + jmethodID ctor=env->GetMethodID(cls,"","()V"); + jfieldID fid_device_slot=env->GetFieldID(cls,"device_slot","I"); + jfieldID fid_stable_id=env->GetFieldID(cls,"stable_id","Ljava/lang/String;"); + jfieldID fid_display_name=env->GetFieldID(cls,"display_name","Ljava/lang/String;"); + jfieldID fid_guest_slot=env->GetFieldID(cls,"guest_slot","I"); + jobjectArray out=env->NewObjectArray(jsize(devices.size()),cls,nullptr); + for(size_t i=0;iNewObject(cls,ctor); + env->SetIntField(item,fid_device_slot,devices[i].device_slot); + jstring stable_id=env->NewStringUTF(devices[i].stable_id.c_str()); + jstring display_name=env->NewStringUTF(devices[i].display_name.c_str()); + env->SetObjectField(item,fid_stable_id,stable_id); + env->SetObjectField(item,fid_display_name,display_name); + env->SetIntField(item,fid_guest_slot,devices[i].guest_slot); + env->SetObjectArrayElement(out,jsize(i),item); + env->DeleteLocalRef(stable_id); + env->DeleteLocalRef(display_name); + env->DeleteLocalRef(item); + } + env->DeleteLocalRef(cls); + return out; } static void j_pause(JNIEnv* env,jobject self){ @@ -494,7 +555,13 @@ int register_Emulator(JNIEnv* env){ { "setup_game_path", "(Lxendroid/emulator/Emulator$Path;)V", (void *) j_setup_game_path }, { "setup_surface", "(Landroid/view/Surface;)V", (void *) j_setup_surface }, { "boot", "()V", (void *) j_boot }, - { "key_event", "(IZI)V", (void *) j_key_event }, + { "key_event", "(IIZI)V", (void *) j_key_event }, + { "input_attach_device", "(Ljava/lang/String;Ljava/lang/String;II)I", (void *) j_input_attach_device }, + { "input_detach_device", "(I)V", (void *) j_input_detach_device }, + { "input_bind_slot", "(II)Z", (void *) j_input_bind_slot }, + { "input_unbind_slot", "(I)V", (void *) j_input_unbind_slot }, + { "input_vibration_state", "()[I", (void *) j_input_vibration_state }, + { "input_list_devices", "()[Lxendroid/emulator/Emulator$InputDeviceInfo;", (void *) j_input_list_devices }, { "quit", "()V", (void *) j_quit }, { "is_running", "()Z", (void *) j_is_running }, { "is_paused", "()Z", (void *) j_is_paused }, diff --git a/emulator-core/src/main/cpp/emulator.h b/emulator-core/src/main/cpp/emulator.h index 21869912e2..17d0188643 100644 --- a/emulator-core/src/main/cpp/emulator.h +++ b/emulator-core/src/main/cpp/emulator.h @@ -6,6 +6,7 @@ #include #include #include +#include namespace ae{ constexpr int BOOT_TYPE_WITH_PATH=1; @@ -31,7 +32,19 @@ namespace ae{ extern void surface_resize(int width, int height); extern void main_thr(); - extern void key_event(int key_code,bool pressed,int value); + struct input_device_entry{ + int device_slot; + std::string stable_id; + std::string display_name; + int guest_slot; + }; + extern void key_event(int device_slot,int key_code,bool pressed,int value); + extern int input_attach_device(const char* stable_id,const char* display_name,int subtype,int preferred_slot); + extern void input_detach_device(int device_slot); + extern std::vector input_list_devices(); + extern bool input_bind_slot(int guest_slot,int device_slot); + extern void input_unbind_slot(int guest_slot); + extern std::vector input_vibration_state(); extern bool is_running(); extern bool is_paused(); extern void pause(); diff --git a/emulator-core/src/main/cpp/xe_android_input_driver.cpp b/emulator-core/src/main/cpp/xe_android_input_driver.cpp index 2d3fc52ac1..cfc9a82006 100644 --- a/emulator-core/src/main/cpp/xe_android_input_driver.cpp +++ b/emulator-core/src/main/cpp/xe_android_input_driver.cpp @@ -14,46 +14,66 @@ namespace xe { namespace hid { namespace android { + namespace { + const std::array& KeyTemplate() { + static const std::array kTemplate = {{ + {ui::VirtualKey::kXInputPadDpadLeft,false,0}, + {ui::VirtualKey::kXInputPadDpadUp,false,0}, + {ui::VirtualKey::kXInputPadDpadRight,false,0}, + {ui::VirtualKey::kXInputPadDpadDown,false,0}, + {ui::VirtualKey::kXInputPadA,false,0}, + {ui::VirtualKey::kXInputPadB,false,0}, + {ui::VirtualKey::kXInputPadX,false,0}, + {ui::VirtualKey::kXInputPadY,false,0}, + {ui::VirtualKey::kXInputPadBack,false,0}, + {ui::VirtualKey::kXInputPadStart,false,0}, + + {ui::VirtualKey::kXInputPadLShoulder,false,0}, + {ui::VirtualKey::kXInputPadRShoulder,false,0}, + {ui::VirtualKey::kXInputPadLThumbPress,false,0}, + {ui::VirtualKey::kXInputPadRThumbPress,false,0}, + {ui::VirtualKey::kXInputPadLTrigger,false,0}, + {ui::VirtualKey::kXInputPadRTrigger,false,0}, + + {ui::VirtualKey::kXInputPadLThumbLeft,false,0}, + {ui::VirtualKey::kXInputPadLThumbUp,false,0}, + {ui::VirtualKey::kXInputPadLThumbRight,false,0}, + {ui::VirtualKey::kXInputPadLThumbDown,false,0}, + {ui::VirtualKey::kXInputPadRThumbLeft,false,0}, + {ui::VirtualKey::kXInputPadRThumbUp,false,0}, + {ui::VirtualKey::kXInputPadRThumbRight,false,0}, + {ui::VirtualKey::kXInputPadRThumbDown,false,0}, + }}; + return kTemplate; + } + } // namespace + AndroidInputDriver::AndroidInputDriver(xe::ui::Window* window, size_t window_z_order) - : InputDriver(window, window_z_order) - ,key_status_({ - {ui::VirtualKey::kXInputPadDpadLeft,false,0}, - {ui::VirtualKey::kXInputPadDpadUp,false,0}, - {ui::VirtualKey::kXInputPadDpadRight,false,0}, - {ui::VirtualKey::kXInputPadDpadDown,false,0}, - {ui::VirtualKey::kXInputPadA,false,0}, - {ui::VirtualKey::kXInputPadB,false,0}, - {ui::VirtualKey::kXInputPadX,false,0}, - {ui::VirtualKey::kXInputPadY,false,0}, - {ui::VirtualKey::kXInputPadBack,false,0}, - {ui::VirtualKey::kXInputPadStart,false,0}, - - {ui::VirtualKey::kXInputPadLShoulder,false,0}, - {ui::VirtualKey::kXInputPadRShoulder,false,0}, - {ui::VirtualKey::kXInputPadLThumbPress,false,0}, - {ui::VirtualKey::kXInputPadRThumbPress,false,0}, - {ui::VirtualKey::kXInputPadLTrigger,false,0}, - {ui::VirtualKey::kXInputPadRTrigger,false,0}, - - {ui::VirtualKey::kXInputPadLThumbLeft,false,0}, - {ui::VirtualKey::kXInputPadLThumbUp,false,0}, - {ui::VirtualKey::kXInputPadLThumbRight,false,0}, - {ui::VirtualKey::kXInputPadLThumbDown,false,0}, - {ui::VirtualKey::kXInputPadRThumbLeft,false,0}, - {ui::VirtualKey::kXInputPadRThumbUp,false,0}, - {ui::VirtualKey::kXInputPadRThumbRight,false,0}, - {ui::VirtualKey::kXInputPadRThumbDown,false,0}, - }),prev_key_status_(this->key_status_){ + : InputDriver(window, window_z_order) { + for (Device& device : devices_) { + device.key_status = KeyTemplate(); + device.prev_key_status = device.key_status; + } } AndroidInputDriver::~AndroidInputDriver()=default; - X_STATUS AndroidInputDriver::Setup() { return X_STATUS_SUCCESS; } + X_STATUS AndroidInputDriver::Setup() { + // The overlay is always available; the app detaches it while a + // physical pad is attached so the pad lands on player 1. + AttachDevice("android-touch", "On-screen controls", 0x01, 0); + return X_STATUS_SUCCESS; + } X_RESULT AndroidInputDriver::GetCapabilities(uint32_t user_index, uint32_t flags,X_INPUT_CAPABILITIES* out_caps) { //XELOGI("AID:GetCapabilities {} {}",user_index, flags); - if (user_index != 0) { - return X_ERROR_DEVICE_NOT_CONNECTED; + { + std::lock_guard lock(devices_mutex_); + if (user_index >= kMaxDevices || !devices_[user_index].present) { + return X_ERROR_DEVICE_NOT_CONNECTED; + } } // TODO(benvanik): confirm with a real XInput controller. @@ -67,18 +87,20 @@ namespace xe { out_caps->gamepad.thumb_ly = (int16_t)0xFFFFu; out_caps->gamepad.thumb_rx = (int16_t)0xFFFFu; out_caps->gamepad.thumb_ry = (int16_t)0xFFFFu; - out_caps->vibration.left_motor_speed = 0; - out_caps->vibration.right_motor_speed = 0; + // Non-zero so titles that gate rumble on capabilities will send it. + out_caps->vibration.left_motor_speed = 0xFFFF; + out_caps->vibration.right_motor_speed = 0xFFFF; return X_ERROR_SUCCESS; } X_RESULT AndroidInputDriver::GetState(uint32_t user_index,X_INPUT_STATE* out_state) { //XELOGI("AID:GetState {}",user_index); - if (user_index != 0) { + std::lock_guard lock(devices_mutex_); + if (user_index >= kMaxDevices || !devices_[user_index].present) { return X_ERROR_DEVICE_NOT_CONNECTED; } - //XELOGI("AID:window()->HasFocus() is_active() {} {}",window()->HasFocus(),is_active()); - packet_number_++; + Device& device = devices_[user_index]; + device.packet_number++; uint16_t buttons = 0; uint8_t left_trigger = 0; @@ -91,7 +113,7 @@ namespace xe { if (/*window()->HasFocus() && */1) { //XELOGI("AID:GetState:HasFocus"); - for (const KeyStatus& ks : key_status_) { + for (const KeyStatus& ks : device.key_status) { if (!ks.pressed) continue; switch (ks.id) { case ui::VirtualKey::kXInputPadA: @@ -136,11 +158,15 @@ namespace xe { case ui::VirtualKey::kXInputPadRShoulder: buttons |= 0x0200; // XINPUT_GAMEPAD_RIGHT_SHOULDER break; + // value < 0 means a digital press (on-screen pad), which + // is full deflection; a physical trigger sends 0-255. case ui::VirtualKey::kXInputPadLTrigger: - left_trigger = 0xFF; + left_trigger = ks.value < 0 ? 0xFF + : uint8_t(ks.value > 0xFF ? 0xFF : ks.value); break; case ui::VirtualKey::kXInputPadRTrigger: - right_trigger = 0xFF; + right_trigger = ks.value < 0 ? 0xFF + : uint8_t(ks.value > 0xFF ? 0xFF : ks.value); break; case ui::VirtualKey::kXInputPadLThumbLeft: thumb_lx =ks.value;//+= SHRT_MIN; @@ -170,7 +196,7 @@ namespace xe { } } - out_state->packet_number = packet_number_; + out_state->packet_number = device.packet_number; out_state->gamepad.buttons = buttons; out_state->gamepad.left_trigger = left_trigger; out_state->gamepad.right_trigger = right_trigger; @@ -184,18 +210,38 @@ namespace xe { X_RESULT AndroidInputDriver::SetState(uint32_t user_index,X_INPUT_VIBRATION* vibration) { //XELOGI( "AID:SetState {}", user_index ); - if (user_index != 0) { + std::lock_guard lock(devices_mutex_); + if (user_index >= kMaxDevices || !devices_[user_index].present) { return X_ERROR_DEVICE_NOT_CONNECTED; } + if (vibration) { + devices_[user_index].vibration_left = vibration->left_motor_speed; + devices_[user_index].vibration_right = vibration->right_motor_speed; + } return X_ERROR_SUCCESS; } + std::vector AndroidInputDriver::VibrationState() { + std::vector out(kMaxDevices * 2, 0); + std::lock_guard lock(devices_mutex_); + for (size_t i = 0; i < kMaxDevices; ++i) { + if (!devices_[i].present) { + continue; + } + out[i * 2] = devices_[i].vibration_left; + out[i * 2 + 1] = devices_[i].vibration_right; + } + return out; + } + X_RESULT AndroidInputDriver::GetKeystroke(uint32_t user_index, uint32_t flags,X_INPUT_KEYSTROKE* out_keystroke) { //XELOGI( "AID:GetKeystroke {} {}", user_index, flags ); - if (user_index != 0) { + std::lock_guard lock(devices_mutex_); + if (user_index >= kMaxDevices || !devices_[user_index].present) { return X_ERROR_DEVICE_NOT_CONNECTED; } + Device& device = devices_[user_index]; /*if (!is_active()) { return X_ERROR_EMPTY; @@ -209,32 +255,28 @@ namespace xe { uint8_t hid_code = 0; int key_status_index=-1; - { - std::lock_guard key_lock(key_status_mutex_); - if (key_status_mask_==0) { - // No keys! - return X_ERROR_EMPTY; - } - // One keystroke per call, lowest index first, clearing only - // its bit; the guest drains the rest by polling until EMPTY. - for(size_t i = 0; i < key_status_.size(); i++){ - if(key_status_mask_&(1<virtual_key = uint16_t(xinput_virtual_key); out_keystroke->unicode = unicode; out_keystroke->flags = keystroke_flags; - out_keystroke->user_index = 0; + out_keystroke->user_index = user_index; out_keystroke->hid_code = hid_code; // X_ERROR_EMPTY if no new keys @@ -254,43 +296,109 @@ namespace xe { } - void AndroidInputDriver::OnKey(int key_index, bool pressed, short value){ - /*if (!is_active()) { + void AndroidInputDriver::OnKey(int device_slot, int key_index, bool pressed, int value){ + if (device_slot < 0 || size_t(device_slot) >= kMaxDevices || + key_index < 0 || size_t(key_index) >= kKeyCount) { return; - }*/ + } - std::lock_guard key_lock(key_status_mutex_); - const bool was_pressed = key_status_[key_index].pressed; - prev_key_status_[key_index] = key_status_[key_index]; + std::lock_guard lock(devices_mutex_); + Device& device = devices_[device_slot]; + if (!device.present) { + return; + } + const bool was_pressed = device.key_status[key_index].pressed; + device.prev_key_status[key_index] = device.key_status[key_index]; - key_status_[key_index].pressed = pressed; - key_status_[key_index].value = value; + device.key_status[key_index].pressed = pressed; + device.key_status[key_index].value = value; // Transitions only: analog axes re-send every motion sample, which // would keep GetKeystroke from ever reaching EMPTY. if (was_pressed != pressed) { - key_status_mask_ |= (1 << key_index); + device.key_status_mask |= (1 << key_index); + } + } + + int AndroidInputDriver::AttachDevice(const std::string& stable_id, + const std::string& display_name, + uint8_t subtype, + int8_t preferred_slot) { + int slot = -1; + { + std::lock_guard lock(devices_mutex_); + for (size_t i = 0; i < kMaxDevices; ++i) { + if (devices_[i].stable_id == stable_id) { + slot = int(i); + break; + } + } + if (slot < 0) { + for (size_t i = 0; i < kMaxDevices; ++i) { + if (!devices_[i].present && devices_[i].stable_id.empty()) { + slot = int(i); + break; + } + } + } + if (slot < 0) { + return -1; + } + Device& device = devices_[slot]; + device.present = true; + device.stable_id = stable_id; + device.display_name = display_name; + device.subtype = subtype; + device.preferred_slot = preferred_slot; + device.key_status = KeyTemplate(); + device.prev_key_status = device.key_status; + device.key_status_mask = 0; } + NotifyDevicesChanged(); + return slot; + } + + void AndroidInputDriver::DetachDevice(int device_slot) { + if (device_slot < 0 || size_t(device_slot) >= kMaxDevices) { + return; + } + { + std::lock_guard lock(devices_mutex_); + Device& device = devices_[device_slot]; + if (!device.present) { + return; + } + device.present = false; + device.vibration_left = 0; + device.vibration_right = 0; + // stable_id is kept so a reconnect reclaims this slot, and with + // it whatever guest slot the binding table holds for the id. + device.key_status = KeyTemplate(); + device.prev_key_status = device.key_status; + device.key_status_mask = 0; + } + NotifyDevicesChanged(); } InputType AndroidInputDriver::GetInputType() const { return InputType::Controller; } std::vector AndroidInputDriver::EnumerateDevices() { - // XenDroid: edge's InputSystem only routes guest input to drivers - // that are bound to a slot (slot_bindings_), and bindings are built - // from EnumerateDevices(). Advertise a single always-present Android - // controller so ReconcileBindings auto-binds it to player 1; both the - // on-screen pad and physical controllers feed this driver's slot 0 - // through GetState(0). std::vector out; - InputDeviceInfo info{}; - info.driver_slot = 0; - info.stable_id = "android-gamepad"; - info.display_name = "Android Controller"; - info.preferred_slot = 0; // player 1 - // subtype defaults to XINPUT_DEVSUBTYPE_GAMEPAD, auto_bind to true. - out.push_back(std::move(info)); + std::lock_guard lock(devices_mutex_); + for (size_t i = 0; i < kMaxDevices; ++i) { + const Device& device = devices_[i]; + if (!device.present) { + continue; + } + InputDeviceInfo info{}; + info.driver_slot = uint8_t(i); + info.stable_id = device.stable_id; + info.display_name = device.display_name; + info.subtype = device.subtype; + info.preferred_slot = device.preferred_slot; + out.push_back(std::move(info)); + } return out; } diff --git a/emulator-core/src/main/cpp/xe_android_input_driver.h b/emulator-core/src/main/cpp/xe_android_input_driver.h index 63db929baf..5559e098bc 100644 --- a/emulator-core/src/main/cpp/xe_android_input_driver.h +++ b/emulator-core/src/main/cpp/xe_android_input_driver.h @@ -10,8 +10,10 @@ #ifndef xendroid_XE_ANDROID_INPUT_DRIVER_H #define xendroid_XE_ANDROID_INPUT_DRIVER_H +#include #include #include +#include #include #include "xenia/base/mutex.h" @@ -30,16 +32,26 @@ namespace xe { int value; }; - // Deliberately not the global critical region: analog axes push a - // sample per motion event and nothing here needs its semantics. - std::mutex key_status_mutex_; + static constexpr size_t kKeyCount = 24; + // Guest slots cap at 4; the surplus lets extra pads stay attached + // and be mapped without unplugging anything. + static constexpr size_t kMaxDevices = 8; - std::array key_status_; - std::array prev_key_status_; - - uint32_t key_status_mask_ = 0; - - uint32_t packet_number_ = 1; + // The index into devices_ is the driver_slot InputSystem binds + // to a guest slot. + struct Device { + bool present = false; + std::string stable_id; + std::string display_name; + uint8_t subtype = 0x01; // XINPUT_DEVSUBTYPE_GAMEPAD + int8_t preferred_slot = -1; + std::array key_status; + std::array prev_key_status; + uint32_t key_status_mask = 0; + uint32_t packet_number = 1; + uint16_t vibration_left = 0; + uint16_t vibration_right = 0; + }; explicit AndroidInputDriver(xe::ui::Window* window, size_t window_z_order); ~AndroidInputDriver() override; @@ -52,12 +64,21 @@ namespace xe { X_RESULT GetKeystroke(uint32_t user_index, uint32_t flags,X_INPUT_KEYSTROKE* out_keystroke) override; InputType GetInputType() const override; - // XenDroid: edge's InputSystem builds guest controller-slot - // bindings from each driver's EnumerateDevices(). Advertise the - // always-present Android controller so it auto-binds to a slot. std::vector EnumerateDevices() override; - void OnKey(int key_index, bool pressed, short value); + // Returns the driver slot to pass to OnKey, or -1 when full. + // Re-attaching a known stable_id reuses its slot, so a pad that + // drops and returns keeps the guest slot it was mapped to. + int AttachDevice(const std::string& stable_id, + const std::string& display_name, uint8_t subtype, + int8_t preferred_slot); + void DetachDevice(int device_slot); + void OnKey(int device_slot, int key_index, bool pressed, int value); + std::vector VibrationState(); + + private: + std::mutex devices_mutex_; + std::array devices_; }; } } diff --git a/emulator-core/src/main/cpp/xendroid_emu.cpp b/emulator-core/src/main/cpp/xendroid_emu.cpp index efba523de1..797c9b14bb 100644 --- a/emulator-core/src/main/cpp/xendroid_emu.cpp +++ b/emulator-core/src/main/cpp/xendroid_emu.cpp @@ -804,19 +804,101 @@ namespace ae{ } - void key_event(int key_code,bool pressed,int value){ + // The android driver is always drivers_[0]: create_input_drivers registers + // exactly one factory. + static xe::hid::android::AndroidInputDriver* android_input_driver(){ static const bool is_android=cvars::hid=="android"; - if(!is_android) return; - // Every hop can still be null while the detached boot thread builds the emulator, - // and input arrives in that window: a controller press on the boot splash, or the - // touch overlay releasing its keys as it leaves composition. - if(!g_windowed_app_ref || !g_windowed_app_ref->emu) return; - xe::hid::InputSystem* input_system=g_windowed_app_ref->emu->input_system(); - // driver(0) indexes the vector, so the count must be checked, not the pointer. - if(!input_system || input_system->driver_count()==0) return; - auto* driver=reinterpret_cast(input_system->driver(0)); - if(!driver) return; - driver->OnKey(key_code,pressed,value); + // Every hop can still be null while the detached boot thread builds the + // emulator, and input arrives in that window: a press on the boot splash, + // or the touch overlay releasing its keys as it leaves composition. + if(!is_android || !g_windowed_app_ref || !g_windowed_app_ref->emu){ + return nullptr; + } + auto* input_system=g_windowed_app_ref->emu->input_system(); + // driver(0) indexes the vector, so check the count, not the pointer. + if(!input_system || input_system->driver_count()==0){ + return nullptr; + } + return reinterpret_cast(input_system->driver(0)); + } + + void key_event(int device_slot,int key_code,bool pressed,int value){ + if(auto* driver=android_input_driver()){ + driver->OnKey(device_slot,key_code,pressed,value); + } + } + + int input_attach_device(const char* stable_id,const char* display_name,int subtype,int preferred_slot){ + auto* driver=android_input_driver(); + if(!driver){ + return -1; + } + return driver->AttachDevice(stable_id?stable_id:"",display_name?display_name:"", + uint8_t(subtype),int8_t(preferred_slot)); + } + + void input_detach_device(int device_slot){ + if(auto* driver=android_input_driver()){ + driver->DetachDevice(device_slot); + } + } + + std::vector input_list_devices(){ + std::vector out; + auto* driver=android_input_driver(); + if(!driver){ + return out; + } + auto* input_system=g_windowed_app_ref->emu->input_system(); + // EnumerateDevices/BindSlot/UnbindSlot mutate the binding table without + // locking themselves; the guest polls GetState off other threads. + auto lock=input_system->lock(); + for(const auto& device:input_system->EnumerateDevices()){ + if(device.driver!=driver){ + continue; + } + input_device_entry entry; + entry.device_slot=device.info.driver_slot; + entry.stable_id=device.info.stable_id; + entry.display_name=device.info.display_name; + entry.guest_slot=device.bound_slot; + out.push_back(std::move(entry)); + } + return out; + } + + bool input_bind_slot(int guest_slot,int device_slot){ + auto* driver=android_input_driver(); + if(!driver || guest_slot<0 || guest_slot>=xe::XUserMaxUserCount){ + return false; + } + auto* input_system=g_windowed_app_ref->emu->input_system(); + auto lock=input_system->lock(); + for(const auto& device:input_system->EnumerateDevices()){ + if(device.driver==driver && device.info.driver_slot==device_slot){ + input_system->BindSlot(uint32_t(guest_slot),driver,uint8_t(device_slot), + device.info.stable_id,device.info.display_name); + return true; + } + } + return false; + } + + std::vector input_vibration_state(){ + auto* driver=android_input_driver(); + if(!driver){ + return {}; + } + return driver->VibrationState(); + } + + void input_unbind_slot(int guest_slot){ + if(!android_input_driver() || guest_slot<0 || guest_slot>=xe::XUserMaxUserCount){ + return; + } + auto* input_system=g_windowed_app_ref->emu->input_system(); + auto lock=input_system->lock(); + input_system->UnbindSlot(uint32_t(guest_slot)); } bool is_running(){ if(!g_windowed_app_ref || !g_windowed_app_ref->emu) return false; diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/hid/input_system.cc b/emulator-core/src/main/cpp/xenia/src/xenia/hid/input_system.cc index 0dd502e565..7924ed3fd2 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/hid/input_system.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/hid/input_system.cc @@ -27,6 +27,10 @@ namespace xe { namespace hid { DEFINE_bool(vibration, true, "Toggle controller vibration.", "HID"); +DEFINE_uint32(vibration_level, 100, + "Controller vibration strength, as a percentage of what the game " + "asks for. Allowed range [0-100].", + "HID"); DEFINE_double(left_stick_deadzone_percentage, 0.0, "Defines deadzone level for left stick. Allowed range [0.0-1.0].", @@ -348,10 +352,16 @@ X_INPUT_VIBRATION InputSystem::ModifyVibrationLevel( X_INPUT_VIBRATION* vibration) { X_INPUT_VIBRATION modified_vibration = *vibration; if (cvars::vibration) { + const uint32_t level = std::min(cvars::vibration_level, 100); + if (level < 100) { + modified_vibration.left_motor_speed = uint16_t( + uint32_t(modified_vibration.left_motor_speed) * level / 100); + modified_vibration.right_motor_speed = uint16_t( + uint32_t(modified_vibration.right_motor_speed) * level / 100); + } return modified_vibration; } - // TODO(Gliniak): Use modifier instead of boolean value. modified_vibration.left_motor_speed = 0; modified_vibration.right_motor_speed = 0; return modified_vibration; diff --git a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xam/profile_manager.cc b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xam/profile_manager.cc index ebea7aa014..71624f3bec 100644 --- a/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xam/profile_manager.cc +++ b/emulator-core/src/main/cpp/xenia/src/xenia/kernel/xam/profile_manager.cc @@ -30,6 +30,11 @@ DEFINE_string(logged_profile_slot_0_xuid, "", DEFINE_string(logged_profile_slot_0_xuid, "", "XUID of the profile to load on boot in slot 0", "Profiles"); #endif +DEFINE_bool(ask_players_on_start, true, + "Ask which profile each controller plays as when more than one is " + "connected. Read by the frontend, not the kernel.", + "HID"); + DEFINE_string(logged_profile_slot_1_xuid, "", "XUID of the profile to load on boot in slot 1", "Profiles"); DEFINE_string(logged_profile_slot_2_xuid, "", diff --git a/emulator-core/src/main/java/xendroid/emulator/Emulator.java b/emulator-core/src/main/java/xendroid/emulator/Emulator.java index 51bee16dc8..c724f99f1a 100644 --- a/emulator-core/src/main/java/xendroid/emulator/Emulator.java +++ b/emulator-core/src/main/java/xendroid/emulator/Emulator.java @@ -109,7 +109,24 @@ public boolean support_custom_driver(){ public native void setup_surface(Surface sf); public native void boot() throws Emulator.BootException; - public native void key_event(int key_code,boolean pressed,int value); + public native void key_event(int device_slot,int key_code,boolean pressed,int value); + // Returns the device slot to pass to key_event, or -1 when the driver is full + // or the emulator has not booted. + public native int input_attach_device(String stable_id,String display_name,int subtype,int preferred_slot); + public native void input_detach_device(int device_slot); + public native boolean input_bind_slot(int guest_slot,int device_slot); + public native void input_unbind_slot(int guest_slot); + public native InputDeviceInfo[] input_list_devices(); + /** Motor speeds per device slot as left/right pairs, 0-65535. */ + public native int[] input_vibration_state(); + + /** One attached pad and the guest slot it feeds; guest_slot -1 = unmapped. */ + public static class InputDeviceInfo{ + public int device_slot; + public String stable_id; + public String display_name; + public int guest_slot; + } public native void quit();