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
This commit is contained in:
jpolo1224
2026-08-23 01:39:03 -04:00
parent daed55c427
commit 06b33abf27
10 changed files with 105 additions and 16 deletions
@@ -72,7 +72,13 @@
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "Savers", __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "Savers", __VA_ARGS__)
#define STACK_DEPTH 32 #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 { typedef struct {
float pos[3]; float pos[3];
@@ -906,6 +906,9 @@ val EN: Map<String, String> = 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.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.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.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.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.hapticStrength.label" to "Vibration Strength",
"pad.scopeHint.global" to "○ Editing GLOBAL controls (all games).", "pad.scopeHint.global" to "○ Editing GLOBAL controls (all games).",
@@ -393,6 +393,17 @@ object ControllerMappings {
com.armsx3.NativeApp.sRumbleEnabled = on 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 // 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 // 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 // authored it), so it tames a too-strong motor or boosts a weak one. Persisted and mirrored
@@ -2157,6 +2157,7 @@ open class MainActivityRuntime : ComponentActivity() {
startAutosaveIntervalJob() startAutosaveIntervalJob()
// Restore the saved rumble master toggle into the native gate (NativeApp.onPadRumble). // Restore the saved rumble master toggle into the native gate (NativeApp.onPadRumble).
NativeApp.sRumbleEnabled = ControllerMappings.rumbleEnabled() NativeApp.sRumbleEnabled = ControllerMappings.rumbleEnabled()
NativeApp.sPhoneRumbleEnabled = ControllerMappings.phoneRumbleEnabled()
// Push the saved haptic strength + achievement-sound volume into their native gates before // 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). // any rumble or unlock sound can fire (both default to 1.0 = as authored until set here).
ControllerMappings.syncHapticIntensity() ControllerMappings.syncHapticIntensity()
@@ -260,6 +260,16 @@ fun PadTab(state: MutableState<Settings>) {
ControllerMappings.setRumbleEnabled(it) ControllerMappings.setRumbleEnabled(it)
refreshToken.intValue++ 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 // 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 // haptics (they share the motor path), so an over-eager motor can be tamed or a weak
// one boosted. 100% = as authored; 0% = off. // one boosted. 100% = as authored; 0% = off.
@@ -101,6 +101,7 @@ internal val SETTINGS_SEARCH_INDEX: List<SettingsSearchEntry> = listOf(
SettingsSearchEntry("pad.section.playerRumble", true, SettingsCategory.Controls), SettingsSearchEntry("pad.section.playerRumble", true, SettingsCategory.Controls),
SettingsSearchEntry("pad.editing.label", true, SettingsCategory.Controls), SettingsSearchEntry("pad.editing.label", true, SettingsCategory.Controls),
SettingsSearchEntry("pad.rumble.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.hapticStrength.label", true, SettingsCategory.Controls),
SettingsSearchEntry("pad.pressureAmount.label", true, SettingsCategory.Controls), SettingsSearchEntry("pad.pressureAmount.label", true, SettingsCategory.Controls),
SettingsSearchEntry("pad.section.analogSticks", true, SettingsCategory.Controls), SettingsSearchEntry("pad.section.analogSticks", true, SettingsCategory.Controls),
@@ -247,6 +247,13 @@ public final class NativeApp {
/** Master rumble toggle. */ /** Master rumble toggle. */
public static volatile boolean sRumbleEnabled = true; 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. */ /** Volume applied to UI sounds played through NativeApp.playSound. */
public static volatile float sSoundVolume = 1.0f; public static volatile float sSoundVolume = 1.0f;
@@ -1390,7 +1390,8 @@ object Rpcs3Bridge {
// called. Two stores, neither connected to the other. // called. Two stores, neither connected to the other.
private val rumbleEnabled: Boolean get() = NativeApp.sRumbleEnabled 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 val ctx = appContext ?: return null
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(ctx.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator (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 @JvmStatic
fun setPadVibration(on: Boolean) { fun setPadVibration(on: Boolean) {
NativeApp.sRumbleEnabled = on NativeApp.sRumbleEnabled = on
@@ -1410,12 +1455,13 @@ object Rpcs3Bridge {
@JvmStatic @JvmStatic
fun startRumblePump() { fun startRumblePump() {
if (rumbleRunning) return if (rumbleRunning) return
val vib = vibrator() ?: return
if (!vib.hasVibrator()) return
rumbleRunning = true rumbleRunning = true
rumbleThread = Thread { rumbleThread = Thread {
var lastAmplitude = 0 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) { while (rumbleRunning) {
val packed = runCatching { RPCSX.instance.getPadRumble(0) }.getOrDefault(0) val packed = runCatching { RPCSX.instance.getPadRumble(0) }.getOrDefault(0)
val large = (packed shr 8) and 0xFF val large = (packed shr 8) and 0xFF
@@ -1428,8 +1474,15 @@ object Rpcs3Bridge {
if (want != lastAmplitude) { if (want != lastAmplitude) {
runCatching { runCatching {
if (want <= 0) { // Resolved per change, not once at startup: a pad connected mid-session
vib.cancel() // 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 { } else {
// Repeating one-shot rather than a fixed duration: the guest // Repeating one-shot rather than a fixed duration: the guest
// decides when rumble stops, and a timed effect would either cut // 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 longArrayOf(0, 60), intArrayOf(0, want.coerceIn(1, 255)), 0
) )
) )
active = vib
} }
} }
lastAmplitude = want lastAmplitude = want
@@ -1446,7 +1500,7 @@ object Rpcs3Bridge {
try { Thread.sleep(30) } catch (_: InterruptedException) { break } try { Thread.sleep(30) } catch (_: InterruptedException) { break }
} }
runCatching { vib.cancel() } runCatching { active?.cancel() }
}.apply { isDaemon = true; name = "rumble-pump"; start() } }.apply { isDaemon = true; name = "rumble-pump"; start() }
} }
@@ -1500,13 +1554,9 @@ object Rpcs3Bridge {
@JvmStatic @JvmStatic
fun touchHaptic() { fun touchHaptic() {
val ctx = appContext ?: return // The phone's motor deliberately, not vibrator(): this is feedback for a finger on the
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // phone's own screen, so it belongs there even when a controller is holding the rumble.
(ctx.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator val vibrator = deviceVibrator() ?: return
} else {
@Suppress("DEPRECATION")
ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
} ?: return
runCatching { runCatching {
vibrator.vibrate(VibrationEffect.createOneShot(10, VibrationEffect.DEFAULT_AMPLITUDE)) vibrator.vibrate(VibrationEffect.createOneShot(10, VibrationEffect.DEFAULT_AMPLITUDE))