Skip to content

Commit 9cee488

Browse files
TideGearLibretroAdmin
authored andcommitted
Android: fix controller rumble motor separation via VibratorManager (API 31+)
RetroArch Android collapsed both libretro rumble channels (RETRO_RUMBLE_STRONG and RETRO_RUMBLE_WEAK) into a single vibration output by OR-merging their amplitudes before calling InputDevice.getVibrator(). The effect type was discarded at the JNI call site, making weak and strong rumble completely indistinguishable on dual-motor controllers. Introduces doVibrateJoypad, a new JNI method that accepts both channels separately. On Android 12+ (API 31) it uses InputDevice.getVibratorManager() to enumerate controller vibrator IDs and drives each motor independently via CombinedVibration.startParallel(). Index 0 maps to the strong (large/low-freq) motor, index 1 to the weak (small/high-freq) motor — consistent across tested controllers but not guaranteed by the Android API; documented as a best-effort heuristic. Falls back to the existing single-vibrator doVibrate() path on Android < 12, single-vibrator controllers, or if doVibrateJoypad is not found at JNI lookup time. The doVibrate() method and "Enable Device Vibration" path are entirely unchanged. compileSdkVersion bumped 30 -> 31 (minimum required for VibratorManager and CombinedVibration at compile time; minSdkVersion unchanged at API 16). No deprecated APIs introduced. C changes are C89 and ISO C++ compatible. Fixes libretro/swanstation#72. Tested on Android 14: - Sony DualShock 4 (Bluetooth and USB) - Sony DualSense (Bluetooth and USB) - 8BitDo Pro 2 in Xbox One mode (Bluetooth)
1 parent ac33677 commit 9cee488

File tree

5 files changed

+182
-15
lines changed

5 files changed

+182
-15
lines changed

frontend/drivers/platform_unix.c

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2262,6 +2262,8 @@ static void frontend_unix_init(void *data)
22622262
"setScreenOrientation", "(I)V");
22632263
GET_METHOD_ID(env, android_app->doVibrate, class,
22642264
"doVibrate", "(IIII)V");
2265+
GET_METHOD_ID(env, android_app->doVibrateJoypad, class,
2266+
"doVibrateJoypad", "(IIII)V");
22652267
GET_METHOD_ID(env, android_app->doHapticFeedback, class,
22662268
"doHapticFeedback", "(I)V");
22672269
GET_METHOD_ID(env, android_app->getUserLanguageString, class,

frontend/drivers/platform_unix.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ struct android_app
173173
jmethodID setScreenOrientation;
174174
jmethodID getUserLanguageString;
175175
jmethodID doVibrate;
176+
jmethodID doVibrateJoypad;
176177
jmethodID doHapticFeedback;
177178

178179
jmethodID isPlayStoreBuild;

input/drivers_joypad/android_joypad.c

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Copyright (C) 2011-2017 - Daniel De Matteis
44
* Copyright (C) 2012-2015 - Michael Lelli
55
* Copyright (C) 2013-2014 - Steven Crowe
6+
* Copyright (C) 2026 - Adam "TideGear" Milecki
67
*
78
* RetroArch is free software: you can redistribute it and/or modify it under the terms * of the GNU General Public License as published by the Free Software Found-
89
* ation, either version 3 of the License, or (at your option) any later version.
@@ -170,33 +171,68 @@ static void android_input_set_rumble_internal(
170171
int8_t id,
171172
enum retro_rumble_effect effect)
172173
{
173-
JNIEnv *env = (JNIEnv*)jni_thread_getenv();
174-
uint16_t new_strength = 0;
174+
JNIEnv *env = (JNIEnv*)jni_thread_getenv();
175175

176176
if (!env)
177177
return;
178178

179+
/* Update the per-channel state independently. */
179180
if (effect == RETRO_RUMBLE_STRONG)
180-
{
181-
new_strength = strength | *last_strength_weak;
182181
*last_strength_strong = strength;
183-
}
184182
else if (effect == RETRO_RUMBLE_WEAK)
185-
{
186-
new_strength = strength | *last_strength_strong;
187183
*last_strength_weak = strength;
184+
185+
/* Controller dual-motor path (Android 12+):
186+
*
187+
* doVibrateJoypad receives both channels separately so the Java side
188+
* can drive each controller motor independently via VibratorManager.
189+
*
190+
* The old code OR-merged strong | weak into a single amplitude before
191+
* calling doVibrate, which destroyed motor separation and made the
192+
* weak (small/high-freq) and strong (large/low-freq) motors feel
193+
* identical. */
194+
if (id >= 0 && g_android->doVibrateJoypad)
195+
{
196+
/* Normalize the 0–65535 libretro range into 0–255 Android amplitude.
197+
* Storing first avoids the JNI zero-value bug noted below. */
198+
int strong_final = (int)((255.0f / 65535.0f) * (float)*last_strength_strong);
199+
int weak_final = (int)((255.0f / 65535.0f) * (float)*last_strength_weak);
200+
201+
/* Pack both 0–255 amplitudes into the uint16_t for change detection:
202+
* high byte = strong amplitude, low byte = weak amplitude. */
203+
uint16_t new_combined = (uint16_t)((strong_final << 8) | weak_final);
204+
205+
if (new_combined != *last_strength)
206+
{
207+
CALL_VOID_METHOD_PARAM(env, g_android->activity->clazz,
208+
g_android->doVibrateJoypad, (jint)id,
209+
(jint)strong_final, (jint)weak_final, (jint)0);
210+
211+
*last_strength = new_combined;
212+
}
213+
return;
188214
}
189215

190-
if (new_strength != *last_strength)
216+
/* Legacy single-vibrator fallback:
217+
* - Device vibration path (id == -1)
218+
* - Android < 12 builds where doVibrateJoypad is unavailable
219+
* OR-merge preserves the original behavior for these cases so that
220+
* controllers still rumble rather than going completely silent. */
191221
{
192-
/* trying to send this value as a JNI param without
193-
* storing it first was causing 0 to be seen on the other side ?? */
194-
int strength_final = (255.0f / 65535.0f) * (float)new_strength;
222+
uint16_t new_strength = *last_strength_strong | *last_strength_weak;
195223

196-
CALL_VOID_METHOD_PARAM(env, g_android->activity->clazz,
197-
g_android->doVibrate, (jint)id, (jint)RETRO_RUMBLE_STRONG, (jint)strength_final, (jint)0);
224+
if (new_strength != *last_strength)
225+
{
226+
/* trying to send this value as a JNI param without
227+
* storing it first was causing 0 to be seen on the other side ?? */
228+
int strength_final = (255.0f / 65535.0f) * (float)new_strength;
229+
230+
CALL_VOID_METHOD_PARAM(env, g_android->activity->clazz,
231+
g_android->doVibrate, (jint)id, (jint)RETRO_RUMBLE_STRONG,
232+
(jint)strength_final, (jint)0);
198233

199-
*last_strength = new_strength;
234+
*last_strength = new_strength;
235+
}
200236
}
201237
}
202238

pkg/android/phoenix-common/src/com/retroarch/browser/retroactivity/RetroActivityCommon.java

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
/* RetroArch - A frontend for libretro.
2+
* Copyright (C) 2026 - Adam "TideGear" Milecki
3+
*
4+
* RetroArch is free software: you can redistribute it and/or modify it under the terms
5+
* of the GNU General Public License as published by the Free Software Foundation,
6+
* either version 3 of the License, or (at your option) any later version.
7+
*
8+
* RetroArch is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
9+
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
10+
* PURPOSE. See the GNU General Public License for more details.
11+
*
12+
* You should have received a copy of the GNU General Public License along with RetroArch.
13+
* If not, see <http://www.gnu.org/licenses/>.
14+
*/
115
package com.retroarch.browser.retroactivity;
216

317
import com.retroarch.BuildConfig;
@@ -27,8 +41,10 @@
2741
import android.os.BatteryManager;
2842
import android.os.Build;
2943
import android.os.PowerManager;
44+
import android.os.CombinedVibration;
3045
import android.os.Vibrator;
3146
import android.os.VibrationEffect;
47+
import android.os.VibratorManager;
3248
import android.util.Log;
3349

3450

@@ -149,6 +165,118 @@ public void doVibrate(int id, int effect, int strength, int oneShot)
149165
}
150166
}
151167

168+
/**
169+
* Vibrates a controller's motors independently for dual-rumble gamepads.
170+
*
171+
* On Android 12+ (API 31) this uses VibratorManager to address each
172+
* controller motor separately, preserving the RETRO_RUMBLE_STRONG /
173+
* RETRO_RUMBLE_WEAK distinction that the legacy single-vibrator path lost
174+
* by OR-merging both channels into one amplitude value.
175+
*
176+
* Falls back to doVibrate (single-vibrator) on Android < 12 or when the
177+
* controller does not expose multiple vibrators.
178+
*
179+
* @param id InputDevice ID of the controller
180+
* @param strongStrength Large / low-frequency motor amplitude (0–255)
181+
* @param weakStrength Small / high-frequency motor amplitude (0–255)
182+
* @param unused Reserved; always pass 0
183+
*/
184+
public void doVibrateJoypad(int id, int strongStrength, int weakStrength, int unused)
185+
{
186+
/* Android 12+ (API 31): attempt the multi-vibrator path. */
187+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
188+
{
189+
if (doVibrateJoypadApi31(id, strongStrength, weakStrength))
190+
return;
191+
}
192+
193+
/* Fallback for Android < 12 or when VibratorManager cannot be used:
194+
* drive the single vibrator with the stronger channel value so the
195+
* controller still rumbles rather than staying silent. */
196+
int fallbackStrength = Math.max(strongStrength, weakStrength);
197+
doVibrate(id, RETRO_RUMBLE_STRONG, fallbackStrength, 0);
198+
}
199+
200+
/**
201+
* Inner implementation of doVibrateJoypad for Android 12+ (API 31).
202+
*
203+
* Uses InputDevice.getVibratorManager() to enumerate the controller's
204+
* vibrator IDs and drive them independently via CombinedVibration so each
205+
* motor receives its own amplitude.
206+
*
207+
* The mapping assumes vibratorIds[0] is the large / low-frequency
208+
* (strong) motor and vibratorIds[1] is the small / high-frequency (weak)
209+
* motor. This ordering has been observed consistently for DualShock 4,
210+
* DualSense, and Xbox controllers on Android 12+, but is NOT guaranteed
211+
* by the Android API — treat this as a best-effort heuristic and code
212+
* defensively.
213+
*
214+
* @return true if vibration was handled, false to trigger fallback
215+
*/
216+
@TargetApi(Build.VERSION_CODES.S)
217+
private boolean doVibrateJoypadApi31(int id, int strongStrength, int weakStrength)
218+
{
219+
InputDevice dev = InputDevice.getDevice(id);
220+
if (dev == null)
221+
return false;
222+
223+
VibratorManager vm = dev.getVibratorManager();
224+
int[] vibratorIds = vm.getVibratorIds();
225+
226+
if (vibratorIds.length == 0)
227+
return false;
228+
229+
if (vibratorIds.length == 1)
230+
{
231+
/* Single-motor controller: use the stronger channel value so the
232+
* controller still rumbles rather than going silent. */
233+
int singleStrength = Math.max(strongStrength, weakStrength);
234+
Vibrator singleVibrator = vm.getVibrator(vibratorIds[0]);
235+
236+
if (singleStrength == 0)
237+
{
238+
singleVibrator.cancel();
239+
return true;
240+
}
241+
242+
long[] timings = {0, 1000};
243+
int[] amps = {0, singleStrength};
244+
singleVibrator.vibrate(
245+
VibrationEffect.createWaveform(timings, amps, 0),
246+
new AudioAttributes.Builder()
247+
.setUsage(AudioAttributes.USAGE_GAME)
248+
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
249+
.build());
250+
return true;
251+
}
252+
253+
/* Dual-motor (or more) path: cancel cleanly when both are zero. */
254+
if (strongStrength == 0 && weakStrength == 0)
255+
{
256+
vm.cancel();
257+
return true;
258+
}
259+
260+
/* Drive both motors with independent looping waveforms. */
261+
long[] timings = {0, 1000};
262+
VibrationEffect strongEffect = VibrationEffect.createWaveform(
263+
timings, new int[]{0, strongStrength}, 0);
264+
VibrationEffect weakEffect = VibrationEffect.createWaveform(
265+
timings, new int[]{0, weakStrength}, 0);
266+
267+
CombinedVibration combined = CombinedVibration.startParallel()
268+
.addVibrator(vibratorIds[0], strongEffect)
269+
.addVibrator(vibratorIds[1], weakEffect)
270+
.combine();
271+
272+
vm.vibrate(combined);
273+
274+
Log.i("RetroActivity", "doVibrateJoypad id=" + id
275+
+ " strong=" + strongStrength + " weak=" + weakStrength
276+
+ " vibrators=" + vibratorIds.length);
277+
return true;
278+
}
279+
152280
public void doHapticFeedback(int effect)
153281
{
154282
getWindow().getDecorView().performHapticFeedback(effect,

pkg/android/phoenix/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ allprojects {
2525
apply plugin: 'com.android.application'
2626

2727
android {
28-
compileSdkVersion 30
28+
compileSdkVersion 31
2929
buildToolsVersion "30.0.3"
3030
ndkVersion "22.0.7026061"
3131

0 commit comments

Comments
 (0)