From 06b33abf27bbfb4eac35337c75333025c2640123 Mon Sep 17 00:00:00 2001 From: jpolo1224 Date: Sun, 23 Aug 2026 01:39:03 -0400 Subject: [PATCH] Send rumble to the controller, not the phone Rumble always went to the phone's own motor: the vibrator lookup asked the system service and never considered the connected pad. On a handheld that is the wrong motor outright, and on a phone-plus-controller setup it buzzes the device sitting in a dock while the pad in hand stays still. Prefer the first connected gamepad or joystick reporting a working motor, falling back to the phone. The target is resolved per state change rather than cached for the pump's lifetime, so connecting or disconnecting a pad mid-session moves rumble with it; the motor that was last started is tracked separately so unplugging mid-rumble cannot leave one buzzing. Add a 'Vibrate the phone' toggle gating only that fallback, so playing on a pad need not mean the phone rumbles along with it. Touch haptics stay on the phone deliberately -- the finger is on the phone's screen. Closes #89 --- 3rdparty/discord-rpc/discord-rpc | 2 +- 3rdparty/libsdl-org/SDL | 2 +- .../armsx3-ui/app/src/main/cpp/savers/gl1.c | 8 +- .../app/src/main/java/com/armsx2/i18n/I18n.kt | 3 + .../com/armsx2/input/ControllerMappings.kt | 11 +++ .../com/armsx2/runtime/MainActivityRuntime.kt | 1 + .../java/com/armsx2/ui/settings/PadTab.kt | 10 +++ .../ui/settingshub/SettingsSearchIndex.kt | 1 + .../src/main/java/com/armsx3/NativeApp.java | 7 ++ .../src/main/java/com/armsx3/Rpcs3Bridge.kt | 76 +++++++++++++++---- 10 files changed, 105 insertions(+), 16 deletions(-) diff --git a/3rdparty/discord-rpc/discord-rpc b/3rdparty/discord-rpc/discord-rpc index fb04b1766..3dc2c326c 160000 --- a/3rdparty/discord-rpc/discord-rpc +++ b/3rdparty/discord-rpc/discord-rpc @@ -1 +1 @@ -Subproject commit fb04b1766bbbe7d2f47985b8b82fded0366efdf9 +Subproject commit 3dc2c326cb4dc5815c6069970c13154898f58d48 diff --git a/3rdparty/libsdl-org/SDL b/3rdparty/libsdl-org/SDL index 147a8ee32..f87239e71 160000 --- a/3rdparty/libsdl-org/SDL +++ b/3rdparty/libsdl-org/SDL @@ -1 +1 @@ -Subproject commit 147a8ee32dbf9ac02f3794964490687b6bbda1bc +Subproject commit f87239e71e42da91ca317a12eefb82cfbf3393eb diff --git a/android/armsx3-ui/app/src/main/cpp/savers/gl1.c b/android/armsx3-ui/app/src/main/cpp/savers/gl1.c index 071ccf26f..643185269 100644 --- a/android/armsx3-ui/app/src/main/cpp/savers/gl1.c +++ b/android/armsx3-ui/app/src/main/cpp/savers/gl1.c @@ -72,7 +72,13 @@ #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "Savers", __VA_ARGS__) #define STACK_DEPTH 32 -#define GL1_MAX_LISTS 16 +/* Sixteen was not enough for two of the savers, and the failure was silent-ish: glGenLists + * returned 0, every subsequent glNewList/glCallList logged "bad list", and the geometry those + * lists held simply never drew. Lattice asks for 20 in one range (NUMOBJECTS), and Skyrocket + * accumulates 16 across separate calls -- flare 4, smoke 5, and seven singles in world.cpp. + * Names are 1-based, so the old ceiling allowed a longest run of 15 and neither could ever + * succeed. Sixty-four is a slot table of a pointer and two ints apiece: about a kilobyte. */ +#define GL1_MAX_LISTS 64 typedef struct { float pos[3]; diff --git a/android/armsx3-ui/app/src/main/java/com/armsx2/i18n/I18n.kt b/android/armsx3-ui/app/src/main/java/com/armsx2/i18n/I18n.kt index 300c58d67..a48031347 100644 --- a/android/armsx3-ui/app/src/main/java/com/armsx2/i18n/I18n.kt +++ b/android/armsx3-ui/app/src/main/java/com/armsx2/i18n/I18n.kt @@ -906,6 +906,9 @@ val EN: Map = mapOf( "pad.players.help" to "The PS3 has seven controller ports and no multitap, so up to seven pads work with no setup. Connect them before launching — the order they first press a button in is the order they are assigned.", "pad.rumble.description" to "Master switch for controller rumble and the device's built-in vibration. Turn off to silence all haptics.", "pad.rumble.label" to "Rumble / Vibration", + "pad.rumblePhone.label" to "Vibrate the phone", + "pad.rumblePhone.description" to + "Use the phone's own motor when no controller has one. Turn this off to keep rumble on the controller only.", "pad.hapticStrength.description" to "Scales all vibration — controller rumble and on-screen touch haptics alike. Below 100% tames a strong motor; above 100% boosts a weak one.", "pad.hapticStrength.label" to "Vibration Strength", "pad.scopeHint.global" to "○ Editing GLOBAL controls (all games).", diff --git a/android/armsx3-ui/app/src/main/java/com/armsx2/input/ControllerMappings.kt b/android/armsx3-ui/app/src/main/java/com/armsx2/input/ControllerMappings.kt index 3d5de07c9..06bae6734 100644 --- a/android/armsx3-ui/app/src/main/java/com/armsx2/input/ControllerMappings.kt +++ b/android/armsx3-ui/app/src/main/java/com/armsx2/input/ControllerMappings.kt @@ -393,6 +393,17 @@ object ControllerMappings { com.armsx3.NativeApp.sRumbleEnabled = on } + // Whether the PHONE's motor may be used. Rumble prefers a connected controller's motor and + // falls back to the phone; this gates only that fallback, so playing on a pad need not mean + // the phone buzzes too. Mirrored into NativeApp.sPhoneRumbleEnabled the same way KEY_RUMBLE + // is — live on change and at app start. Default on, so a phone-only player is unaffected. + private const val KEY_RUMBLE_PHONE = "pad.rumble.phone" + fun phoneRumbleEnabled(): Boolean = MainActivityRuntime.prefs.getBoolean(KEY_RUMBLE_PHONE, true) + fun setPhoneRumbleEnabled(on: Boolean) { + MainActivityRuntime.prefs.edit { putBoolean(KEY_RUMBLE_PHONE, on) } + com.armsx3.NativeApp.sPhoneRumbleEnabled = on + } + // Haptic strength: one multiplier scaling ALL vibration — controller rumble AND on-screen // touch ticks both funnel through NativeApp.rumbleOne. 0..200 % (100 = as the game/UI // authored it), so it tames a too-strong motor or boosts a weak one. Persisted and mirrored diff --git a/android/armsx3-ui/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt b/android/armsx3-ui/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt index 7c9739b33..5491161e2 100644 --- a/android/armsx3-ui/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt +++ b/android/armsx3-ui/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt @@ -2157,6 +2157,7 @@ open class MainActivityRuntime : ComponentActivity() { startAutosaveIntervalJob() // Restore the saved rumble master toggle into the native gate (NativeApp.onPadRumble). NativeApp.sRumbleEnabled = ControllerMappings.rumbleEnabled() + NativeApp.sPhoneRumbleEnabled = ControllerMappings.phoneRumbleEnabled() // Push the saved haptic strength + achievement-sound volume into their native gates before // any rumble or unlock sound can fire (both default to 1.0 = as authored until set here). ControllerMappings.syncHapticIntensity() diff --git a/android/armsx3-ui/app/src/main/java/com/armsx2/ui/settings/PadTab.kt b/android/armsx3-ui/app/src/main/java/com/armsx2/ui/settings/PadTab.kt index 1ae570707..f3dcf7d8a 100644 --- a/android/armsx3-ui/app/src/main/java/com/armsx2/ui/settings/PadTab.kt +++ b/android/armsx3-ui/app/src/main/java/com/armsx2/ui/settings/PadTab.kt @@ -260,6 +260,16 @@ fun PadTab(state: MutableState) { ControllerMappings.setRumbleEnabled(it) refreshToken.intValue++ } + // Rumble prefers a connected controller's motor; this gates only the phone fallback, + // so playing on a pad need not mean the phone buzzes along with it. + ToggleRow( + str("pad.rumblePhone.label"), + ControllerMappings.phoneRumbleEnabled(), + description = str("pad.rumblePhone.description"), + ) { + ControllerMappings.setPhoneRumbleEnabled(it) + refreshToken.intValue++ + } // Vibration strength: one multiplier over BOTH controller rumble and on-screen touch // haptics (they share the motor path), so an over-eager motor can be tamed or a weak // one boosted. 100% = as authored; 0% = off. diff --git a/android/armsx3-ui/app/src/main/java/com/armsx2/ui/settingshub/SettingsSearchIndex.kt b/android/armsx3-ui/app/src/main/java/com/armsx2/ui/settingshub/SettingsSearchIndex.kt index 3ade61c54..63bb159a9 100644 --- a/android/armsx3-ui/app/src/main/java/com/armsx2/ui/settingshub/SettingsSearchIndex.kt +++ b/android/armsx3-ui/app/src/main/java/com/armsx2/ui/settingshub/SettingsSearchIndex.kt @@ -101,6 +101,7 @@ internal val SETTINGS_SEARCH_INDEX: List = listOf( SettingsSearchEntry("pad.section.playerRumble", true, SettingsCategory.Controls), SettingsSearchEntry("pad.editing.label", true, SettingsCategory.Controls), SettingsSearchEntry("pad.rumble.label", true, SettingsCategory.Controls), + SettingsSearchEntry("pad.rumblePhone.label", true, SettingsCategory.Controls), SettingsSearchEntry("pad.hapticStrength.label", true, SettingsCategory.Controls), SettingsSearchEntry("pad.pressureAmount.label", true, SettingsCategory.Controls), SettingsSearchEntry("pad.section.analogSticks", true, SettingsCategory.Controls), diff --git a/android/armsx3-ui/app/src/main/java/com/armsx3/NativeApp.java b/android/armsx3-ui/app/src/main/java/com/armsx3/NativeApp.java index e89cf9e03..0889cf1c3 100644 --- a/android/armsx3-ui/app/src/main/java/com/armsx3/NativeApp.java +++ b/android/armsx3-ui/app/src/main/java/com/armsx3/NativeApp.java @@ -247,6 +247,13 @@ public final class NativeApp { /** Master rumble toggle. */ public static volatile boolean sRumbleEnabled = true; + /** + * Whether the PHONE's own motor may be used. A controller's motor is always allowed; this + * only gates the fallback, so a user playing on a pad can stop the phone buzzing in their + * pocket or dock without giving up rumble entirely (issue #89). Default on. + */ + public static volatile boolean sPhoneRumbleEnabled = true; + /** Volume applied to UI sounds played through NativeApp.playSound. */ public static volatile float sSoundVolume = 1.0f; diff --git a/android/armsx3-ui/app/src/main/java/com/armsx3/Rpcs3Bridge.kt b/android/armsx3-ui/app/src/main/java/com/armsx3/Rpcs3Bridge.kt index ba3dd9b34..c8c80a624 100644 --- a/android/armsx3-ui/app/src/main/java/com/armsx3/Rpcs3Bridge.kt +++ b/android/armsx3-ui/app/src/main/java/com/armsx3/Rpcs3Bridge.kt @@ -1390,7 +1390,8 @@ object Rpcs3Bridge { // called. Two stores, neither connected to the other. private val rumbleEnabled: Boolean get() = NativeApp.sRumbleEnabled - private fun vibrator(): Vibrator? { + /** The phone's own motor. */ + private fun deviceVibrator(): Vibrator? { val ctx = appContext ?: return null return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { (ctx.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator @@ -1400,6 +1401,50 @@ object Rpcs3Bridge { } } + /** + * The motor in a connected controller, if one has it. + * + * Rumble went to the PHONE even with a controller attached, because this only ever asked the + * system service. The pad is what the game is addressing; the phone buzzing in its place is + * wrong, and on a handheld it is the wrong motor entirely (issue #89). + * + * The first gamepad or joystick with a working motor wins, in InputDevice id order, so a + * single connected pad is unambiguous. + */ + private fun controllerVibrator(): Vibrator? = runCatching { + for (id in android.view.InputDevice.getDeviceIds()) { + val dev = android.view.InputDevice.getDevice(id) ?: continue + + val isPad = (dev.sources and android.view.InputDevice.SOURCE_GAMEPAD) == + android.view.InputDevice.SOURCE_GAMEPAD || + (dev.sources and android.view.InputDevice.SOURCE_JOYSTICK) == + android.view.InputDevice.SOURCE_JOYSTICK + + if (!isPad) continue + + val v = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dev.vibratorManager?.defaultVibrator + } else { + @Suppress("DEPRECATION") + dev.vibrator + } + + if (v != null && v.hasVibrator()) return@runCatching v + } + + null + }.getOrNull() + + /** + * Where rumble goes: the controller if one can take it, otherwise the phone. + * + * Resolved on demand rather than cached, because a pad can be connected or disconnected + * mid-session. Callers only ask when the motor state changes, so enumerating devices is not a + * per-frame cost. + */ + private fun vibrator(): Vibrator? = + controllerVibrator() ?: deviceVibrator().takeIf { NativeApp.sPhoneRumbleEnabled } + @JvmStatic fun setPadVibration(on: Boolean) { NativeApp.sRumbleEnabled = on @@ -1410,12 +1455,13 @@ object Rpcs3Bridge { @JvmStatic fun startRumblePump() { if (rumbleRunning) return - val vib = vibrator() ?: return - if (!vib.hasVibrator()) return rumbleRunning = true rumbleThread = Thread { var lastAmplitude = 0 + // The motor we last started, so it can be stopped even if the target has since + // changed underneath us -- otherwise unplugging a pad mid-rumble leaves it buzzing. + var active: Vibrator? = null while (rumbleRunning) { val packed = runCatching { RPCSX.instance.getPadRumble(0) }.getOrDefault(0) val large = (packed shr 8) and 0xFF @@ -1428,8 +1474,15 @@ object Rpcs3Bridge { if (want != lastAmplitude) { runCatching { - if (want <= 0) { - vib.cancel() + // Resolved per change, not once at startup: a pad connected mid-session + // has to take over from the phone, and vice versa on disconnect. + val vib = vibrator() + + active?.takeIf { it !== vib }?.cancel() + active = null + + if (want <= 0 || vib == null) { + vib?.cancel() } else { // Repeating one-shot rather than a fixed duration: the guest // decides when rumble stops, and a timed effect would either cut @@ -1439,6 +1492,7 @@ object Rpcs3Bridge { longArrayOf(0, 60), intArrayOf(0, want.coerceIn(1, 255)), 0 ) ) + active = vib } } lastAmplitude = want @@ -1446,7 +1500,7 @@ object Rpcs3Bridge { try { Thread.sleep(30) } catch (_: InterruptedException) { break } } - runCatching { vib.cancel() } + runCatching { active?.cancel() } }.apply { isDaemon = true; name = "rumble-pump"; start() } } @@ -1500,13 +1554,9 @@ object Rpcs3Bridge { @JvmStatic fun touchHaptic() { - val ctx = appContext ?: return - val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - (ctx.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator - } else { - @Suppress("DEPRECATION") - ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator - } ?: return + // The phone's motor deliberately, not vibrator(): this is feedback for a finger on the + // phone's own screen, so it belongs there even when a controller is holding the rumble. + val vibrator = deviceVibrator() ?: return runCatching { vibrator.vibrate(VibrationEffect.createOneShot(10, VibrationEffect.DEFAULT_AMPLITUDE))