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 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];
@@ -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.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).",
@@ -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
@@ -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()
@@ -260,6 +260,16 @@ fun PadTab(state: MutableState<Settings>) {
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.
@@ -101,6 +101,7 @@ internal val SETTINGS_SEARCH_INDEX: List<SettingsSearchEntry> = 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),
@@ -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;
@@ -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))