From 5901d4b0f199f5123f76c83f83a226c56a3666a3 Mon Sep 17 00:00:00 2001 From: TideGear Date: Tue, 28 Apr 2026 23:22:47 -0700 Subject: [PATCH 1/6] feat: PC-accurate per-controller vibration on top of upstream multi-controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layered the vibration improvements from the pre-cleanup branch (commit 878469f7^) onto the contributor's multi-controller PR (#1261, merged upstream as dacbfa04). Upstream's slot infrastructure (getBufferForSlot/setControllerForSlot/resolveControllerSlot) is preserved unchanged; the additions are purely vibration quality. What this changes: - WinHandler.java: per-slot rumble poller wakes via FileObserver (inotify) instead of a 20 ms sleep loop, dispatches dual-motor rumble via VibratorManager (API 31+) with ascending-ID motor sort, applies a 240 ms keepalive cycle so long rumbles don't get cut off, refreshes the InputDevice handle periodically to handle hot-swap mid-rumble, and scales amplitude by the user's intensity preference. - evshim.c: per-player atomic g_keepalive_active flag + per-player last_rumble cache + inotify-based wake replace the prior 5 ms poll fallback, eliminating dropped/stuck rumbles on rapid game-side pulses. Includes the narrow-rumble re-entry guard (4a085981). - libevshim.so: rebuilt against new evshim.c (26288 bytes). Settings UI (ControllerTab): - Vibration Mode dropdown: off / controller / device. (Pre-cleanup had a 'both' option; dropped per design — pick one target, not both.) - Vibration Intensity slider 0-100%, hidden when mode is 'off'. Persistence: - ContainerData: vibrationMode + vibrationIntensity fields with saver / restorer wiring. - ContainerUtils: roundtrips both fields between PrefManager defaults, ContainerData, and Container.extras. - PrefManager: VIBRATION_MODE + VIBRATION_INTENSITY prefs with normalizeVibrationModeInput() helper for safe deserialization. - XServerScreen: pushes per-container vibration mode + intensity into WinHandler at game launch. What is intentionally left as upstream: - All multi-controller routing, slot assignment, hot-plug, and the ControllerManager / PhysicalControllerHandler integration. The contributor's design for those is what the PR keeps. build-evshim.ps1 and the SDL2 stub header are added to support recompiling libevshim.so without an SDL2 SDK install. --- app/src/main/cpp/extras/evshim.c | 130 +++- app/src/main/cpp/extras/sdl2_stub/SDL2/SDL.h | 66 ++ .../main/java/app/gamenative/PrefManager.kt | 28 + .../ui/component/dialog/ControllerTab.kt | 46 ++ .../ui/screen/xserver/XServerScreen.kt | 6 + .../app/gamenative/utils/ContainerUtils.kt | 10 + .../com/winlator/container/ContainerData.kt | 8 + .../com/winlator/winhandler/WinHandler.java | 568 +++++++++++++++--- app/src/main/jniLibs/arm64-v8a/libevshim.so | Bin 11712 -> 26288 bytes app/src/main/res/values/strings.xml | 8 + build-evshim.ps1 | 60 ++ 11 files changed, 827 insertions(+), 103 deletions(-) create mode 100644 app/src/main/cpp/extras/sdl2_stub/SDL2/SDL.h create mode 100644 build-evshim.ps1 diff --git a/app/src/main/cpp/extras/evshim.c b/app/src/main/cpp/extras/evshim.c index 6424cb8aca..6ef473ec14 100644 --- a/app/src/main/cpp/extras/evshim.c +++ b/app/src/main/cpp/extras/evshim.c @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -14,6 +15,9 @@ #include #include #include +#include +#include +#include static int g_debug_enabled = 0; @@ -28,6 +32,13 @@ static int rumble_fd[MAX_GAMEPADS] = {-1}; static void *handle = NULL; static pthread_mutex_t shm_mutex = PTHREAD_MUTEX_INITIALIZER; +/* Set to 1 on the keepalive thread before calling SDL_JoystickRumble so the + * resulting synchronous (same-thread) re-entrant OnRumble() call does not + * overwrite shared memory (which may have been zeroed by a stop command that + * raced the keepalive). Thread-local so legitimate OnRumble calls from other + * threads are never suppressed. */ +static __thread int t_keepalive_active = 0; + struct gamepad_io { int16_t lx, ly, rx, ry, lt, rt; uint8_t btn[15]; @@ -47,6 +58,18 @@ static int (*p_SDL_JoystickSetVirtualHat)(SDL_Joystick *joystick, int hat, uint8 static void (*p_SDL_PumpEvents)(void); static void (*p_SDL_Delay)(uint32_t ms); static void (*p_SDL_GetVersion)(SDL_version *); +static int (*p_SDL_JoystickRumble)(SDL_Joystick *, uint16_t, uint16_t, uint32_t); + +/* Per-player rumble state for SDL keepalive. + * Wine/XInput "set and forget" semantics expect motors to stay on until + * explicitly stopped, but SDL's internal timer auto-expires rumble after + * the duration Wine passes (~1 s). We periodically re-send the last + * non-zero values to reset that timer so the auto-expiry never fires. */ +static uint16_t last_rumble_low [MAX_GAMEPADS]; +static uint16_t last_rumble_high[MAX_GAMEPADS]; + +#define RUMBLE_KEEPALIVE_TICKS 100 /* 100 × 5 ms = 500 ms */ +#define RUMBLE_KEEPALIVE_DUR_MS 2000 /* SDL expiry reset window */ #define GETFUNCPTR(name)\ @@ -63,11 +86,26 @@ static int OnRumble(void *userdata, int idx = (int)(intptr_t)userdata; if (idx < 0 || idx >= MAX_GAMEPADS || rumble_fd[idx] < 0) return -1; - uint16_t vals[2] = { low_frequency_rumble, high_frequency_rumble }; + /* When the SDL keepalive re-invokes SDL_JoystickRumble to reset SDL's + * internal expiry timer, it synchronously calls back into OnRumble. + * We must NOT update last_rumble or pwrite in that case: the game may + * have already sent OnRumble(0,0) to stop vibration, and re-writing + * the old non-zero values would restart rumble on the Android side. */ + if (t_keepalive_active) { + LOGD("Rumble P%d low=%u high=%u [keepalive noop]\n", idx, + low_frequency_rumble, high_frequency_rumble); + return 0; + } + + pthread_mutex_lock(&shm_mutex); - pthread_mutex_lock(&shm_mutex); /* NEW */ + last_rumble_low [idx] = low_frequency_rumble; + last_rumble_high[idx] = high_frequency_rumble; + + uint16_t vals[2] = { low_frequency_rumble, high_frequency_rumble }; ssize_t w = pwrite(rumble_fd[idx], vals, sizeof(vals), 32); - pthread_mutex_unlock(&shm_mutex); /* NEW */ + + pthread_mutex_unlock(&shm_mutex); if (w != (ssize_t)sizeof(vals)) LOGE("Rumble write failed (P%d): %s\n", idx, strerror(errno)); @@ -93,7 +131,7 @@ static void *vjoy_updater(void *arg) int fd = read_fd[idx]; if (fd < 0) { - LOGE("P%d: read_fd not initialised – aborting thread\n", idx); + LOGE("P%d: read_fd not initialised - aborting thread\n", idx); return NULL; } @@ -105,14 +143,45 @@ static void *vjoy_updater(void *arg) struct gamepad_io cur, last_state = {0}; - LOGI("VJOY UPDATER P%d running (PID %d)\n", idx, getpid()); + /* Set up inotify to wake immediately when WinHandler writes new input + * state (offsets 0-31) rather than sleeping a fixed 5 ms between reads. + * The same watch also fires on rumble writes (offset 32-35); those + * wakeups are benign - we re-read and find the input portion unchanged. */ + char watch_path[256]; + if (idx == 0) { + snprintf(watch_path, sizeof watch_path, + "/data/data/app.gamenative/files/imagefs/tmp/gamepad.mem"); + } else { + snprintf(watch_path, sizeof watch_path, + "/data/data/app.gamenative/files/imagefs/tmp/gamepad%d.mem", idx); + } + int ino_fd = inotify_init1(IN_NONBLOCK); + if (ino_fd >= 0) { + if (inotify_add_watch(ino_fd, watch_path, IN_MODIFY) < 0) { + LOGE("P%d: inotify_add_watch failed: %s - falling back to 5 ms poll\n", + idx, strerror(errno)); + close(ino_fd); + ino_fd = -1; + } + } else { + LOGE("P%d: inotify_init1 failed: %s - falling back to 5 ms poll\n", + idx, strerror(errno)); + } + + /* Wall-clock keepalive: replaces tick counter so the rumble refresh + * cadence stays correct regardless of how fast inotify wakes the loop. */ + struct timespec last_keepalive; + clock_gettime(CLOCK_MONOTONIC, &last_keepalive); + + LOGI("VJOY UPDATER P%d running (PID %d, inotify=%s)\n", + idx, getpid(), ino_fd >= 0 ? "on" : "off"); for (;;) { pthread_mutex_lock(&shm_mutex); + ssize_t n = pread(fd, &cur, sizeof cur, 0); + pthread_mutex_unlock(&shm_mutex); - ssize_t n = read(fd, &cur, sizeof cur); - - if (n == sizeof cur && memcmp(&cur, &last_state, sizeof cur) != 0) { + if (n == sizeof cur && memcmp(&cur, &last_state, offsetof(struct gamepad_io, low_freq_rumble)) != 0) { p_SDL_JoystickSetVirtualAxis (js, 0, cur.lx); p_SDL_JoystickSetVirtualAxis (js, 1, cur.ly); @@ -132,12 +201,48 @@ static void *vjoy_updater(void *arg) LOGE("P%d: read error: %s\n", idx, strerror(errno)); } - pthread_mutex_unlock(&shm_mutex); + /* Re-send last non-zero rumble to SDL periodically so its internal + * expiry timer never fires the false OnRumble(0,0). This preserves + * XInput "set and forget" semantics through the SDL translation. + * Use wall clock so the 500 ms cadence is correct even when inotify + * wakes the loop much faster than the old fixed 5 ms sleep did. */ + if (p_SDL_JoystickRumble) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + long elapsed_ms = (now.tv_sec - last_keepalive.tv_sec) * 1000L + + (now.tv_nsec - last_keepalive.tv_nsec) / 1000000L; + if (elapsed_ms >= RUMBLE_KEEPALIVE_TICKS * 5) { /* 100 * 5 ms = 500 ms */ + last_keepalive = now; + uint16_t kl, kh; + pthread_mutex_lock(&shm_mutex); + kl = last_rumble_low[idx]; + kh = last_rumble_high[idx]; + pthread_mutex_unlock(&shm_mutex); + if (kl != 0 || kh != 0) { + /* Flag this player's slot before calling SDL so that the + * synchronous OnRumble() re-entry is recognised as a + * keepalive and does not overwrite shared memory. */ + t_keepalive_active = 1; + p_SDL_JoystickRumble(js, kl, kh, RUMBLE_KEEPALIVE_DUR_MS); + t_keepalive_active = 0; + LOGD("Rumble keepalive P%d low=%u high=%u\n", idx, kl, kh); + } + } + } - p_SDL_Delay(5); + /* Wait up to 5 ms for the next file modification then drain the + * inotify queue so events do not accumulate across iterations. + * Falls back to p_SDL_Delay(5) if inotify is unavailable. */ + if (ino_fd >= 0) { + struct pollfd pfd = { ino_fd, POLLIN, 0 }; + poll(&pfd, 1, 5); + /* Drain all queued events (non-blocking). */ + char ibuf[256]; + while (read(ino_fd, ibuf, sizeof ibuf) > 0) {} + } else { + p_SDL_Delay(5); + } } - - return NULL; } __attribute__((constructor)) @@ -156,6 +261,7 @@ static void initialize_all_pads(void) GETFUNCPTR(SDL_JoystickSetVirtualAxis); GETFUNCPTR(SDL_JoystickSetVirtualButton); GETFUNCPTR(SDL_JoystickSetVirtualHat); GETFUNCPTR(SDL_PumpEvents); GETFUNCPTR(SDL_Delay); GETFUNCPTR(SDL_GetVersion); + GETFUNCPTR(SDL_JoystickRumble); p_SDL_Init(SDL_INIT_JOYSTICK); diff --git a/app/src/main/cpp/extras/sdl2_stub/SDL2/SDL.h b/app/src/main/cpp/extras/sdl2_stub/SDL2/SDL.h new file mode 100644 index 0000000000..16df2ce5b1 --- /dev/null +++ b/app/src/main/cpp/extras/sdl2_stub/SDL2/SDL.h @@ -0,0 +1,66 @@ +/* + * Minimal SDL2 type stubs for cross-compiling evshim.c with the Android NDK. + * Only the types, constants, and struct layouts that evshim.c actually uses + * are defined here. All SDL functions are loaded at runtime via dlsym(). + * + * Struct layout must match SDL2 >= 2.24.0 (SDL_VirtualJoystickDesc). + */ +#ifndef SDL_STUB_H +#define SDL_STUB_H + +#include + +typedef uint8_t Uint8; +typedef uint16_t Uint16; +typedef uint32_t Uint32; +typedef int32_t Sint32; + +#define SDLCALL + +#define SDL_INIT_JOYSTICK 0x00000200u + +typedef struct SDL_Joystick SDL_Joystick; + +typedef struct SDL_version { + Uint8 major; + Uint8 minor; + Uint8 patch; +} SDL_version; + +typedef enum { + SDL_JOYSTICK_TYPE_UNKNOWN = 0, + SDL_JOYSTICK_TYPE_GAMECONTROLLER, + SDL_JOYSTICK_TYPE_WHEEL, + SDL_JOYSTICK_TYPE_ARCADE_STICK, + SDL_JOYSTICK_TYPE_FLIGHT_STICK, + SDL_JOYSTICK_TYPE_DANCE_PAD, + SDL_JOYSTICK_TYPE_GUITAR, + SDL_JOYSTICK_TYPE_DRUM_KIT, + SDL_JOYSTICK_TYPE_ARCADE_PAD, + SDL_JOYSTICK_TYPE_THROTTLE +} SDL_JoystickType; + +#define SDL_VIRTUAL_JOYSTICK_DESC_VERSION 1 + +typedef struct SDL_VirtualJoystickDesc { + Uint16 version; + Uint16 type; + Uint16 naxes; + Uint16 nbuttons; + Uint16 nhats; + Uint16 vendor_id; + Uint16 product_id; + Uint16 padding; + Uint32 button_mask; + Uint32 axis_mask; + const char *name; + void *userdata; + void (SDLCALL *Update)(void *userdata); + void (SDLCALL *SetPlayerIndex)(void *userdata, int player_index); + int (SDLCALL *Rumble)(void *userdata, Uint16 low_frequency_rumble, Uint16 high_frequency_rumble); + int (SDLCALL *RumbleTriggers)(void *userdata, Uint16 left_rumble, Uint16 right_rumble); + int (SDLCALL *SetLED)(void *userdata, Uint8 red, Uint8 green, Uint8 blue); + int (SDLCALL *SendEffect)(void *userdata, const void *data, int size); +} SDL_VirtualJoystickDesc; + +#endif /* SDL_STUB_H */ diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index ab8ac7d486..9e04834a31 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -216,6 +216,34 @@ object PrefManager { setPref(SHARPNESS_DENOISE, value.coerceIn(0, 100)) } + private val VALID_VIBRATION_MODES = setOf("off", "controller", "device") + private const val DEFAULT_VIBRATION_MODE = "controller" + + /** Normalizes a vibration mode string to a known value, falling back to the default. */ + private fun normalizeVibrationMode(value: String?): String { + val v = value?.trim()?.lowercase().orEmpty() + return if (v in VALID_VIBRATION_MODES) v else DEFAULT_VIBRATION_MODE + } + + /** + * Returns a value in `VALID_VIBRATION_MODES`, for prefs, container extras, or WinHandler. + */ + fun normalizeVibrationModeInput(value: String?): String = normalizeVibrationMode(value) + + private val VIBRATION_MODE = stringPreferencesKey("vibration_mode") + var vibrationMode: String + get() = normalizeVibrationMode(getPref(VIBRATION_MODE, DEFAULT_VIBRATION_MODE)) + set(value) { + setPref(VIBRATION_MODE, normalizeVibrationMode(value)) + } + + private val VIBRATION_INTENSITY = intPreferencesKey("vibration_intensity") + var vibrationIntensity: Int + get() = getPref(VIBRATION_INTENSITY, 100).coerceIn(0, 100) + set(value) { + setPref(VIBRATION_INTENSITY, value.coerceIn(0, 100)) + } + private val CONTAINER_VARIANT = stringPreferencesKey("container_variant") var containerVariant: String get() = getPref(CONTAINER_VARIANT, Container.DEFAULT_VARIANT) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt index 86cdd7a0b0..d573eddf45 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt @@ -1,8 +1,18 @@ package app.gamenative.ui.component.dialog +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.ui.component.settings.SettingsListDropdown import app.gamenative.ui.theme.settingsTileColors @@ -10,10 +20,12 @@ import app.gamenative.ui.theme.settingsTileColorsAlt import com.alorma.compose.settings.ui.SettingsGroup import com.alorma.compose.settings.ui.SettingsSwitch import com.winlator.container.Container +import kotlin.math.roundToInt @Composable fun ControllerTabContent(state: ContainerConfigState, default: Boolean) { val config = state.config.value + val normalizedVibrationMode = PrefManager.normalizeVibrationModeInput(config.vibrationMode) SettingsGroup() { if (!default) { @@ -51,6 +63,40 @@ fun ControllerTabContent(state: ContainerConfigState, default: Boolean) { state.config.value = config.copy(dinputMapperType = if (index == 0) 1 else 2) }, ) + val vibrationModes = listOf( + stringResource(R.string.vibration_mode_option_off), + stringResource(R.string.vibration_mode_option_controller), + stringResource(R.string.vibration_mode_option_device), + ) + val vibrationModeValues = listOf("off", "controller", "device") + val vibrationModeIndex = vibrationModeValues.indexOf(normalizedVibrationMode).coerceAtLeast(0) + SettingsListDropdown( + colors = settingsTileColors(), + title = { Text(text = stringResource(R.string.vibration_mode)) }, + value = vibrationModeIndex, + items = vibrationModes, + onItemSelected = { index -> + state.config.value = config.copy(vibrationMode = vibrationModeValues[index]) + }, + ) + if (normalizedVibrationMode != "off") { + var intensitySlider by remember(config.vibrationIntensity) { + mutableIntStateOf(config.vibrationIntensity.coerceIn(0, 100)) + } + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text(text = stringResource(R.string.vibration_intensity)) + Slider( + value = intensitySlider.toFloat(), + onValueChange = { newValue -> + val clamped = newValue.roundToInt().coerceIn(0, 100) + intensitySlider = clamped + state.config.value = config.copy(vibrationIntensity = clamped) + }, + valueRange = 0f..100f, + ) + Text(text = "$intensitySlider%") + } + } SettingsSwitch( colors = settingsTileColorsAlt(), title = { Text(text = stringResource(R.string.shooter_mode_toggle)) }, diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 4ec0a62fc8..79aee7a63a 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -1837,6 +1837,12 @@ fun XServerScreen( } handler.setPreferredInputApi(PreferredInputApi.values()[container.inputType]) handler.setDInputMapperType(container.dinputMapperType) + handler.setVibrationMode( + PrefManager.normalizeVibrationModeInput( + container.getExtra("vibrationMode", "controller"), + ), + ) + handler.setVibrationIntensity(container.getExtra("vibrationIntensity", "100").toIntOrNull() ?: 100) if (container.isDisableMouseInput()) { PluviaApp.touchpadView?.setTouchscreenMouseDisabled(true) } else if (container.isTouchscreenMode()) { diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 6e0f9618e6..1d9d872e0f 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -143,6 +143,8 @@ object ContainerUtils { sharpnessEffect = PrefManager.sharpnessEffect, sharpnessLevel = PrefManager.sharpnessLevel, sharpnessDenoise = PrefManager.sharpnessDenoise, + vibrationMode = PrefManager.vibrationMode, + vibrationIntensity = PrefManager.vibrationIntensity, ) } @@ -204,6 +206,8 @@ object ContainerUtils { PrefManager.sharpnessEffect = containerData.sharpnessEffect PrefManager.sharpnessLevel = containerData.sharpnessLevel PrefManager.sharpnessDenoise = containerData.sharpnessDenoise + PrefManager.vibrationMode = containerData.vibrationMode + PrefManager.vibrationIntensity = containerData.vibrationIntensity } fun toContainerData(container: Container): ContainerData { @@ -317,6 +321,8 @@ object ContainerUtils { sharpnessEffect = container.getExtra("sharpnessEffect", "None"), sharpnessLevel = container.getExtra("sharpnessLevel", "100").toIntOrNull() ?: 100, sharpnessDenoise = container.getExtra("sharpnessDenoise", "100").toIntOrNull() ?: 100, + vibrationMode = container.getExtra("vibrationMode", "controller"), + vibrationIntensity = container.getExtra("vibrationIntensity", "100").toIntOrNull() ?: 100, ) } @@ -483,6 +489,8 @@ object ContainerUtils { container.putExtra("sharpnessEffect", containerData.sharpnessEffect) container.putExtra("sharpnessLevel", containerData.sharpnessLevel.toString()) container.putExtra("sharpnessDenoise", containerData.sharpnessDenoise.toString()) + container.putExtra("vibrationMode", containerData.vibrationMode) + container.putExtra("vibrationIntensity", containerData.vibrationIntensity.toString()) try { container.language = containerData.language } catch (e: Exception) { @@ -852,6 +860,8 @@ object ContainerUtils { portraitMode = PrefManager.portraitMode, externalDisplayMode = PrefManager.externalDisplayInputMode, externalDisplaySwap = PrefManager.externalDisplaySwap, + vibrationMode = PrefManager.vibrationMode, + vibrationIntensity = PrefManager.vibrationIntensity, ) } diff --git a/app/src/main/java/com/winlator/container/ContainerData.kt b/app/src/main/java/com/winlator/container/ContainerData.kt index 5d0da2b8e7..b1b617c04a 100644 --- a/app/src/main/java/com/winlator/container/ContainerData.kt +++ b/app/src/main/java/com/winlator/container/ContainerData.kt @@ -24,6 +24,10 @@ data class ContainerData( val executablePath: String = "", val installPath: String = "", val showFPS: Boolean = false, + /** Vibration target: "off", "controller", "device" **/ + val vibrationMode: String = "controller", + /** Vibration intensity percentage (0-100) **/ + val vibrationIntensity: Int = 100, val launchRealSteam: Boolean = false, val allowSteamUpdates: Boolean = false, val steamType: String = "normal", @@ -114,6 +118,8 @@ data class ContainerData( "executablePath" to state.executablePath, "installPath" to state.installPath, "showFPS" to state.showFPS, + "vibrationMode" to state.vibrationMode, + "vibrationIntensity" to state.vibrationIntensity, "launchRealSteam" to state.launchRealSteam, "allowSteamUpdates" to state.allowSteamUpdates, "steamType" to state.steamType, @@ -176,6 +182,8 @@ data class ContainerData( executablePath = savedMap["executablePath"] as String, installPath = savedMap["installPath"] as String, showFPS = savedMap["showFPS"] as Boolean, + vibrationMode = (savedMap["vibrationMode"] as? String) ?: "controller", + vibrationIntensity = (savedMap["vibrationIntensity"] as? Int) ?: 100, launchRealSteam = savedMap["launchRealSteam"] as Boolean, allowSteamUpdates = savedMap["allowSteamUpdates"] as Boolean, steamType = (savedMap["steamType"] as? String) ?: "normal", diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index e0228ea004..862cdd4fd9 100644 --- a/app/src/main/java/com/winlator/winhandler/WinHandler.java +++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java @@ -1,10 +1,18 @@ package com.winlator.winhandler; +import android.annotation.TargetApi; import android.content.Context; import android.content.Intent; +import android.media.AudioAttributes; import android.net.Uri; +import android.os.Build; +import android.os.CombinedVibration; +import android.os.FileObserver; +import android.os.SystemClock; +import android.os.VibrationAttributes; import android.os.VibrationEffect; import android.os.Vibrator; +import android.os.VibratorManager; import android.util.Log; import android.view.InputDevice; import android.view.KeyEvent; @@ -40,8 +48,12 @@ import java.nio.channels.FileChannel; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Locale; +import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executors; @@ -80,14 +92,65 @@ public class WinHandler { private final short[] lastLowFreqs = new short[MAX_PLAYERS]; private final short[] lastHighFreqs = new short[MAX_PLAYERS]; private final boolean[] isRumbling = new boolean[MAX_PLAYERS]; + // Wall-clock timestamps (ms) used instead of tick counters so that the + // keepalive cadence is correct even when the poller wakes early (FileObserver) + // or late (GC pause). + private final long[] lastKeepaliveMs = new long[MAX_PLAYERS]; + private final long[] lastDeviceRefreshMs = new long[MAX_PLAYERS]; + // Notified by FileObserver.onEvent() and by stop() to wake the poller thread. + private final Object rumbleNotifyLock = new Object(); + private final FileObserver[] gamepadFileObservers = new FileObserver[MAX_PLAYERS]; + // Per-player phone-vibrator amplitude. Written by the rumble poller thread; read by + // vibrateDevice/stopVibrationForPlayer which also run on the same thread, so no lock needed. + private final int[] playerPhoneAmplitudes = new int[MAX_PLAYERS]; + // How often (ms) to re-send rumble to the controller to reset its internal expiry timer. + private static final int RUMBLE_KEEPALIVE_MS = 240; // was: 12 ticks × 20 ms + private static final int CONTROLLER_RUMBLE_MS = 500; + private static final int DEVICE_RUMBLE_MS = 60000; + // How long before the device one-shot expires to issue a refresh (DEVICE_RUMBLE_MS - 5 s). + private static final long DEVICE_RUMBLE_REFRESH_MS = DEVICE_RUMBLE_MS - 5_000L; private boolean isShowingAssignDialog = false; private Context activity; private final java.util.Set ignoredDeviceIds = new java.util.HashSet<>(); + // Motor ID pairs that have already been logged once; prevents flooding logcat every keepalive tick. + private final Set loggedRumbleMotorIds = new HashSet<>(); + + private static final Set VALID_VIBRATION_MODES = new HashSet<>(Arrays.asList( + "off", "controller", "device")); + private static final String DEFAULT_VIBRATION_MODE = "controller"; + + // Pre-built attribute objects — constructing these on every keepalive tick + // (every 240 ms × up to 4 players) causes unnecessary allocations. + // AudioAttributes is available on all supported API levels. + private static final AudioAttributes AUDIO_ATTRS_GAME = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .build(); + // VibrationAttributes requires API 31; USAGE_MEDIA requires API 33. + // Computed once at construction time to avoid repeated API-version checks. + private final VibrationAttributes vibrationAttrs; + + private volatile String vibrationMode = "controller"; + private volatile int vibrationIntensity = 100; public void setInputControlsView(InputControlsView view) { this.inputControlsView = view; } + /** Sets the vibration routing mode (off/controller/device), normalizing and validating input. */ + public void setVibrationMode(String mode) { + if (mode == null) { + this.vibrationMode = DEFAULT_VIBRATION_MODE; + } else { + String normalized = mode.trim().toLowerCase(Locale.US); + this.vibrationMode = VALID_VIBRATION_MODES.contains(normalized) ? normalized : DEFAULT_VIBRATION_MODE; + } + } + + /** Sets the vibration intensity percentage, clamped to 0–100. */ + public void setVibrationIntensity(int intensity) { + this.vibrationIntensity = Math.max(0, Math.min(100, intensity)); + } + public enum PreferredInputApi { AUTO, DINPUT, @@ -115,48 +178,77 @@ public WinHandler(XServer xServer, XServerView xServerView) { this.xServerView = xServerView; this.controllerManager = ControllerManager.getInstance(); this.activity = xServerView.getContext(); + + // Build VibrationAttributes once — requires API 31, USAGE_MEDIA requires API 33. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + VibrationAttributes.Builder vab = new VibrationAttributes.Builder(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + vab.setUsage(VibrationAttributes.USAGE_MEDIA); + } + this.vibrationAttrs = vab.build(); + } else { + this.vibrationAttrs = null; + } } + /** Re-scans connected devices and re-initializes per-slot ExternalController references. */ public void refreshControllerMappings() { Log.d(TAG, "Refreshing controller assignments from settings..."); - // Route through setControllerForSlot() so any in-progress rumble is cancelled and the - // per-slot rumble cache (lastLowFreqs/lastHighFreqs/isRumbling) is reset on swap. - // Direct writes would leave stale rumble state that can mask the first transition for - // the incoming controller. - for (int slot = 0; slot < MAX_PLAYERS; slot++) { - setControllerForSlot(slot, null); + currentController = null; + for (int i = 0; i < extraControllers.length; i++) { + extraControllers[i] = null; } controllerManager.scanForDevices(); - for (int slot = 0; slot < MAX_PLAYERS; slot++) { - InputDevice device = controllerManager.getAssignedDeviceForSlot(slot); - if (device == null) continue; - ExternalController controller = ExternalController.getController(device.getId()); - if (controller == null) continue; - if (slot == 0) controller.setContext(activity); - setControllerForSlot(slot, controller); - Log.i(TAG, "Initialized Player " + (slot + 1) + " with: " + device.getName()); + InputDevice p1Device = controllerManager.getAssignedDeviceForSlot(0); + if (p1Device != null) { + currentController = ExternalController.getController(p1Device.getId()); + if (currentController != null) { + currentController.setContext(activity); + Log.i(TAG, "Initialized Player 1 with: " + p1Device.getName()); + } + } + // Initialize Extra Players (2, 3, 4) + for (int i = 0; i < extraControllers.length; i++) { + // Player 2 is slot 1, which corresponds to extraControllers[0] + InputDevice extraDevice = controllerManager.getAssignedDeviceForSlot(i + 1); + if (extraDevice != null) { + extraControllers[i] = ExternalController.getController(extraDevice.getId()); + Log.i(TAG, "Initialized Player " + (i + 2) + " with: " + extraDevice.getName()); + } } } + /** Returns the shared-memory buffer for the given player slot (0-based), or null if unavailable. */ public MappedByteBuffer getBufferForSlot(int slot) { if (slot == 0) return gamepadBuffer; if (slot > 0 && slot <= extraGamepadBuffers.length) return extraGamepadBuffers[slot - 1]; return null; } + /** Returns the ExternalController assigned to the given player slot, or null if none. */ public ExternalController getControllerForSlot(int slot) { if (slot == 0) return currentController; if (slot > 0 && slot <= extraControllers.length) return extraControllers[slot - 1]; return null; } + /** Assigns a controller to a player slot, stopping any active vibration on the previous occupant. */ public void setControllerForSlot(int slot, ExternalController controller) { if (slot < 0 || slot >= MAX_PLAYERS) return; ExternalController old = getControllerForSlot(slot); if (old != null && old != controller) { - stopVibrationForSlot(slot, old); + stopVibrationForPlayer(slot); lastLowFreqs[slot] = 0; lastHighFreqs[slot] = 0; + lastDeviceRefreshMs[slot] = SystemClock.elapsedRealtime(); + // Zero the shared-memory rumble bytes so the next poll doesn't see + // the old controller's stale rumble values and immediately vibrate + // the new controller. + MappedByteBuffer buf = getBufferForSlot(slot); + if (buf != null) { + buf.putShort(32, (short) 0); + buf.putShort(34, (short) 0); + } } if (slot == 0) { currentController = controller; return; } if (slot > 0 && slot <= extraControllers.length) extraControllers[slot - 1] = controller; @@ -359,8 +451,16 @@ private void startSendThread() { }); } + /** Stops all WinHandler threads, cancels vibration for all players, and closes the UDP socket. */ public void stop() { this.running = false; + // Wake the rumble poller so it can observe running==false and exit. + synchronized (rumbleNotifyLock) { + rumbleNotifyLock.notifyAll(); + } + for (int p = 0; p < MAX_PLAYERS; p++) { + stopVibrationForPlayer(p); + } DatagramSocket datagramSocket = this.socket; if (datagramSocket != null) { datagramSocket.close(); @@ -404,21 +504,10 @@ private void handleRequest(byte requestCode, final int port) throws IOException final ControlsProfile profile = inputControlsView.getProfile(); final boolean useVirtualGamepad = inputControlsView != null && profile != null && profile.isVirtualGamepad(); int processId = this.receiveData.getInt(); - - Log.d(TAG, "GET_GAMEPAD: isXInput=" + isXInput + " notify=" + notify + " processId=" + processId - + " preferredApi=" + preferredInputApi + " currentController=" + (currentController != null ? currentController.getName() + "(#" + currentController.getDeviceId() + ")" : "null") - + " useVirtualGamepad=" + useVirtualGamepad); - if (!useVirtualGamepad && ((externalController = this.currentController) == null || !externalController.isConnected())) { - // Use ControllerManager as the single source of truth for slot 0 - InputDevice p1Device = controllerManager.getAssignedDeviceForSlot(0); - Log.d(TAG, "GET_GAMEPAD: no current controller, ControllerManager slot0 device=" + (p1Device != null ? p1Device.getName() : "null")); - if (p1Device != null) { - this.currentController = ExternalController.getController(p1Device.getId()); - } + this.currentController = ExternalController.getController(0); } boolean enabled2 = this.currentController != null || useVirtualGamepad; - Log.d(TAG, "GET_GAMEPAD: final enabled=" + enabled2 + " controller=" + (currentController != null ? currentController.getName() + "(#" + currentController.getDeviceId() + ")" : "null")); if (enabled2) { switch (this.preferredInputApi) { case DINPUT: @@ -510,6 +599,9 @@ private void handleRequest(byte requestCode, final int port) throws IOException final boolean useVirtualGamepad2 = inputControlsView != null && profile2 != null && profile2.isVirtualGamepad(); ExternalController externalController2 = this.currentController; final boolean enabled3 = externalController2 != null || useVirtualGamepad2; + if (externalController2 != null && externalController2.getDeviceId() != gamepadId) { + this.currentController = null; + } addAction(() -> { sendData.rewind(); sendData.put(RequestCodes.GET_GAMEPAD_STATE); @@ -572,10 +664,6 @@ public void start() { } catch (UnknownHostException e2) { } } - // Pre-register controllers from saved assignments so games that check at - // startup sees a controller before any input arrives - initializeAssignedControllers(); - this.running = true; startSendThread(); Executors.newSingleThreadExecutor().execute(() -> { @@ -597,87 +685,381 @@ public void start() { }); startRumblePoller(); - running = true; - startSendThread(); } + /** + * Starts a background thread that reacts to rumble changes in shared-memory buffers. + * + *

Instead of sleeping a fixed interval, the thread waits on {@code rumbleNotifyLock}. + * A {@link FileObserver} per player slot watches the corresponding {@code gamepad.mem} file; + * when evshim's {@code pwrite()} at offset 32 triggers {@code IN_MODIFY}, the observer + * calls {@code notifyAll()} and the thread wakes within microseconds. + * + *

The keepalive path (periodic controller re-rumble to prevent motor auto-expiry) still + * fires on a wall-clock schedule via the {@code wait(timeoutMs)} overload. + */ + @SuppressWarnings("deprecation") // FileObserver(String, int) deprecated API 29; File ctor not available below Q private void startRumblePoller() { - // poller skips case where controller is null and we - // do NOT vibrate phone as of now to prevent issues with docked users. - // TODO: add phone vibration option in upcoming ux when no controller device connected - rumblePollerThread = new Thread(() -> { - while (running) { - try { - for (int slot = 0; slot < MAX_PLAYERS; slot++) { - MappedByteBuffer buffer = getBufferForSlot(slot); - if (buffer == null) continue; + final String[] memPaths = { + "/data/data/app.gamenative/files/imagefs/tmp/gamepad.mem", + "/data/data/app.gamenative/files/imagefs/tmp/gamepad1.mem", + "/data/data/app.gamenative/files/imagefs/tmp/gamepad2.mem", + "/data/data/app.gamenative/files/imagefs/tmp/gamepad3.mem", + }; - short lowFreq = buffer.getShort(32); - short highFreq = buffer.getShort(34); + for (int i = 0; i < MAX_PLAYERS; i++) { + final String path = memPaths[i]; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + gamepadFileObservers[i] = new FileObserver(new File(path), FileObserver.MODIFY) { + @Override public void onEvent(int event, String filename) { + synchronized (rumbleNotifyLock) { rumbleNotifyLock.notifyAll(); } + } + }; + } else { + gamepadFileObservers[i] = new FileObserver(path, FileObserver.MODIFY) { + @Override public void onEvent(int event, String filename) { + synchronized (rumbleNotifyLock) { rumbleNotifyLock.notifyAll(); } + } + }; + } + gamepadFileObservers[i].startWatching(); + } - if (lowFreq != lastLowFreqs[slot] || highFreq != lastHighFreqs[slot]) { - ExternalController controller = getControllerForSlot(slot); + rumblePollerThread = new Thread(() -> { + long now = SystemClock.elapsedRealtime(); + for (int p = 0; p < MAX_PLAYERS; p++) { + lastKeepaliveMs[p] = now; + lastDeviceRefreshMs[p] = now; + } + + while (running) { + now = SystemClock.elapsedRealtime(); - // case: disable vibration, attempt to disable safely + for (int p = 0; p < MAX_PLAYERS; p++) { + try { + MappedByteBuffer buf = getBufferForSlot(p); + if (buf == null) continue; + short lowFreq = buf.getShort(32); + short highFreq = buf.getShort(34); + boolean changed = lowFreq != lastLowFreqs[p] || highFreq != lastHighFreqs[p]; + if (changed) { + lastLowFreqs[p] = lowFreq; + lastHighFreqs[p] = highFreq; + lastKeepaliveMs[p] = now; + lastDeviceRefreshMs[p] = now; if (lowFreq == 0 && highFreq == 0) { - lastLowFreqs[slot] = lowFreq; - lastHighFreqs[slot] = highFreq; - stopVibrationForSlot(slot, controller); - - // case: controller exists and vibration exists - } else if (controller != null) { - // Only mark as delivered when we can actually vibrate - lastLowFreqs[slot] = lowFreq; - lastHighFreqs[slot] = highFreq; - startVibrationForSlot(slot, controller, lowFreq, highFreq); + stopVibrationForPlayer(p); + } else { + startVibrationForPlayer(p, lowFreq, highFreq, false); + } + } else if (isRumbling[p]) { + long elapsedKeepalive = now - lastKeepaliveMs[p]; + if (elapsedKeepalive >= RUMBLE_KEEPALIVE_MS) { + lastKeepaliveMs[p] = now; + long elapsedDeviceRefresh = now - lastDeviceRefreshMs[p]; + boolean deviceRefresh = elapsedDeviceRefresh >= DEVICE_RUMBLE_REFRESH_MS; + if (deviceRefresh) lastDeviceRefreshMs[p] = now; + refreshVibrationForPlayer(p, lowFreq, highFreq, deviceRefresh); } - // else: controller not yet adopted for this slot — don't update - // lastFreqs so the poller retries on the next tick } + } catch (Exception e) { + // Buffer may be unmapped; continue polling + } + } + + // Compute how long to wait: until the next keepalive deadline among + // all rumbling players. If no player is rumbling, wait indefinitely + // (until a FileObserver notification or stop() wakes us). + long waitMs = Long.MAX_VALUE; + for (int p = 0; p < MAX_PLAYERS; p++) { + if (isRumbling[p]) { + long timeToNextKeepalive = RUMBLE_KEEPALIVE_MS - (now - lastKeepaliveMs[p]); + if (timeToNextKeepalive < waitMs) waitMs = timeToNextKeepalive; } - } catch (Exception e) { - continue; } + if (waitMs <= 0) waitMs = 1; // never spin; ensure we yield to other threads try { - Thread.sleep(20); // Poll for new commands 50 times per second + synchronized (rumbleNotifyLock) { + // wait(0) means indefinite; cap at keepalive interval when rumbling. + rumbleNotifyLock.wait(waitMs == Long.MAX_VALUE ? 0 : waitMs); + } } catch (InterruptedException e) { break; } } + + // Clean up inotify watches when the poller exits. + for (FileObserver obs : gamepadFileObservers) { + if (obs != null) obs.stopWatching(); + } }); + rumblePollerThread.setName("rumble-poller"); rumblePollerThread.start(); } - private void startVibrationForSlot(int slot, ExternalController controller, short lowFreq, short highFreq) { - int unsignedLowFreq = lowFreq & 0xFFFF; - int unsignedHighFreq = highFreq & 0xFFFF; - int dominantRumble = Math.max(unsignedLowFreq, unsignedHighFreq); - int amplitude = Math.round((float) dominantRumble / 65535.0f * 254.0f) + 1; - if (amplitude > 255) amplitude = 255; - if (amplitude <= 1) { - stopVibrationForSlot(slot, controller); + /** Converts a raw 16-bit XInput rumble value (0–65535) to a 0–255 amplitude, scaled by intensity percent. */ + private int scaleAmplitude(short rawFreq, int intensityPercent) { + int unsigned = rawFreq & 0xFFFF; + if (unsigned == 0 || intensityPercent == 0) return 0; + // Map full 16-bit range to 1–255 so that any non-zero game rumble produces + // a non-zero amplitude, then scale by the user's intensity preference. + int base = (int) Math.round(unsigned * 255.0 / 65535.0); + return Math.min(255, Math.max(1, (base * intensityPercent) / 100)); + } + + /** Issues a one-shot vibration on a single vibrator, respecting amplitude control availability. */ + private void vibrateSingle(Vibrator vibrator, int amplitude, int durationMs) { + if (amplitude <= 0) { vibrator.cancel(); return; } + int amp = Math.min(255, amplitude); + int finalAmp = vibrator.hasAmplitudeControl() ? amp : VibrationEffect.DEFAULT_AMPLITUDE; + VibrationEffect effect = VibrationEffect.createOneShot(durationMs, finalAmp); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && vibrationAttrs != null) { + vibrator.vibrate(effect, vibrationAttrs); + } else { + vibrator.vibrate(effect, AUDIO_ATTRS_GAME); + } + } + + /** + * Drives per-motor rumble through VibratorManager (API 31+). + * + *

Motor identification strategy: + *

Motor identification: Sort vibrator IDs ascending. The kernel evdev layer enumerates + * vibrators in HID descriptor order, which for XInput-compatible controllers is + * heavy-then-light (low-freq first), matching ascending ID order for most drivers. + * The IDs are logged on first use so swapped-motor behaviour on untested hardware can be + * diagnosed and reported. + * + *

Falls back to a blended single-motor vibration when only one vibrator is available. + */ + @TargetApi(31) + private boolean rumbleViaVibratorManager(VibratorManager vm, short lowFreq, short highFreq) { + int[] ids = vm.getVibratorIds(); + if (ids.length == 0) return false; + + int highAmp = scaleAmplitude(highFreq, vibrationIntensity); + int lowAmp = scaleAmplitude(lowFreq, vibrationIntensity); + if (lowAmp == 0 && highAmp == 0) { vm.cancel(); return true; } + + // Determine which ID drives the low-freq (heavy/left) motor and which drives + // the high-freq (light/right) motor by sorting IDs ascending. + int lowMotorId = ids[0]; + int highMotorId = ids.length >= 2 ? ids[1] : ids[0]; + + if (ids.length >= 2) { + if (ids[0] > ids[1]) { lowMotorId = ids[1]; highMotorId = ids[0]; } + String motorKey = lowMotorId + "_" + highMotorId; + if (loggedRumbleMotorIds.add(motorKey)) { + Log.d(TAG, "Rumble motors: lowMotor=" + lowMotorId + " highMotor=" + highMotorId); + } + } + + CombinedVibration.ParallelCombination combo = CombinedVibration.startParallel(); + boolean anyAdded = false; + + if (ids.length >= 2) { + if (lowAmp > 0) { + int a = vm.getVibrator(lowMotorId).hasAmplitudeControl() ? lowAmp : VibrationEffect.DEFAULT_AMPLITUDE; + combo.addVibrator(lowMotorId, VibrationEffect.createOneShot(CONTROLLER_RUMBLE_MS, a)); + anyAdded = true; + } + if (highAmp > 0) { + int a = vm.getVibrator(highMotorId).hasAmplitudeControl() ? highAmp : VibrationEffect.DEFAULT_AMPLITUDE; + combo.addVibrator(highMotorId, VibrationEffect.createOneShot(CONTROLLER_RUMBLE_MS, a)); + anyAdded = true; + } + } else { + int blended = Math.min(255, (int)(lowAmp * 0.80 + highAmp * 0.33)); + if (blended > 0) { + int a = vm.getVibrator(ids[0]).hasAmplitudeControl() ? blended : VibrationEffect.DEFAULT_AMPLITUDE; + combo.addVibrator(ids[0], VibrationEffect.createOneShot(CONTROLLER_RUMBLE_MS, a)); + anyAdded = true; + } + } + + if (!anyAdded) { vm.cancel(); return true; } + vm.vibrate(combo.combine(), vibrationAttrs); + return true; + } + + /** + * Resolves the physical InputDevice for a player slot. + * Checks ControllerManager slot assignment first, then falls back to + * the slot's ExternalController. For P0 only, if there is exactly one + * detected gamepad and no mapping above, that device is used — with multiple + * pads connected, returning null avoids rumbling the wrong controller. + */ + private InputDevice resolveInputDeviceForPlayer(int player) { + InputDevice device = controllerManager.getAssignedDeviceForSlot(player); + if (device != null) return device; + + ExternalController ctrl = getControllerForSlot(player); + if (ctrl != null) { + device = InputDevice.getDevice(ctrl.getDeviceId()); + if (device != null) return device; + } + + if (player == 0) { + List detected = controllerManager.getDetectedDevices(); + if (detected.size() == 1) { + return detected.get(0); + } + } + return null; + } + + /** Vibrates the physical controller assigned to [player], trying VibratorManager first, then legacy Vibrator. */ + private boolean vibrateController(int player, short lowFreq, short highFreq) { + InputDevice device = resolveInputDeviceForPlayer(player); + if (device == null) { + Log.w(TAG, "Rumble P" + player + ": no physical controller found"); + return false; + } + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + VibratorManager vm = device.getVibratorManager(); + if (rumbleViaVibratorManager(vm, lowFreq, highFreq)) { + return true; + } + } + + Vibrator v = device.getVibrator(); + if (v != null && v.hasVibrator()) { + int lowMSB = scaleAmplitude(lowFreq, vibrationIntensity); + int highMSB = scaleAmplitude(highFreq, vibrationIntensity); + int blended = Math.min(255, (int)(lowMSB * 0.80 + highMSB * 0.33)); + vibrateSingle(v, blended, CONTROLLER_RUMBLE_MS); + return true; + } + Log.w(TAG, "Rumble P" + player + ": no vibrators available on '" + device.getName() + "'"); + } catch (Exception e) { + Log.e(TAG, "Rumble P" + player + ": exception vibrating controller", e); + } + return false; + } + + /** + * Vibrates the Android device's built-in vibrator. + * + *

Stores this player's computed amplitude in {@code playerPhoneAmplitudes[player]}, then + * applies the maximum across all players so that a single phone-vibrator command always + * reflects the loudest active player rather than whichever player happened to write last. + */ + private void vibrateDevice(int player, short lowFreq, short highFreq) { + try { + int lowMSB = scaleAmplitude(lowFreq, vibrationIntensity); + int highMSB = scaleAmplitude(highFreq, vibrationIntensity); + int rawAmplitude = Math.min(255, (int)(lowMSB * 0.80 + highMSB * 0.33)); + + if (rawAmplitude > 0) { + float curved = (float) Math.pow((float) rawAmplitude / 255f, 0.6f); + playerPhoneAmplitudes[player] = Math.max(1, Math.round(curved * 255)); + } else { + playerPhoneAmplitudes[player] = 0; + } + + int combinedAmp = 0; + for (int amp : playerPhoneAmplitudes) { + if (amp > combinedAmp) combinedAmp = amp; + } + + Vibrator phoneVibrator = (Vibrator) activity.getSystemService(Context.VIBRATOR_SERVICE); + if (phoneVibrator == null || !phoneVibrator.hasVibrator()) return; + + if (combinedAmp <= 0) { + phoneVibrator.cancel(); + } else { + vibrateSingle(phoneVibrator, combinedAmp, DEVICE_RUMBLE_MS); + } + } catch (Exception e) { + Log.e(TAG, "Rumble: exception vibrating device", e); + } + } + + /** Routes vibration to either the controller or the device based on the current vibration mode. */ + private void startVibrationForPlayer(int player, short lowFreq, short highFreq, boolean skipDevice) { + if ("off".equals(vibrationMode)) return; + + isRumbling[player] = true; + + if ("controller".equals(vibrationMode)) { + vibrateController(player, lowFreq, highFreq); + } else if ("device".equals(vibrationMode)) { + if (!skipDevice) vibrateDevice(player, lowFreq, highFreq); + } + } + + /** + * Periodic refresh for ongoing vibration. Controller rumble uses short pulses + * and must be refreshed frequently. Device vibration uses a long one-shot and + * only needs refresh when {@code deviceRefresh} signals the one-shot is about + * to expire. + */ + private void refreshVibrationForPlayer(int player, short lowFreq, short highFreq, boolean deviceRefresh) { + if ("off".equals(vibrationMode)) return; + if ("device".equals(vibrationMode)) { + if (deviceRefresh) vibrateDevice(player, lowFreq, highFreq); return; } - if (controller == null) return; - InputDevice device = InputDevice.getDevice(controller.getDeviceId()); - if (device == null) return; - Vibrator controllerVibrator = device.getVibrator(); - if (controllerVibrator == null || !controllerVibrator.hasVibrator()) return; - isRumbling[slot] = true; - controllerVibrator.vibrate(VibrationEffect.createOneShot(50, amplitude)); + vibrateController(player, lowFreq, highFreq); } - private void stopVibrationForSlot(int slot, ExternalController controller) { - if (!isRumbling[slot]) return; - isRumbling[slot] = false; // handle before early returns - disconnected or null controller leaves slot, assures to disable - if (controller == null) return; - InputDevice device = InputDevice.getDevice(controller.getDeviceId()); - if (device == null) return; - Vibrator controllerVibrator = device.getVibrator(); - if (controllerVibrator == null || !controllerVibrator.hasVibrator()) return; - controllerVibrator.cancel(); + /** Cancels all vibration for a player, only stopping the device vibrator if no other player is rumbling. */ + private void stopVibrationForPlayer(int player) { + if (!isRumbling[player]) return; + isRumbling[player] = false; + + try { + InputDevice device = resolveInputDeviceForPlayer(player); + if (device != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + VibratorManager vm = device.getVibratorManager(); + if (vm.getVibratorIds().length > 0) { + // Cancel via VibratorManager when motors are available. + vm.cancel(); + } else { + // VibratorManager reported no motors — vibrateController fell back + // to device.getVibrator(), so cancel that same vibrator. + Vibrator fallback = device.getVibrator(); + if (fallback != null && fallback.hasVibrator()) { + fallback.cancel(); + } + } + } else { + Vibrator vibrator = device.getVibrator(); + if (vibrator != null && vibrator.hasVibrator()) { + vibrator.cancel(); + } + } + } + } catch (Exception e) { + Log.e(TAG, "Error cancelling controller vibration", e); + } + + if ("device".equals(vibrationMode)) { + playerPhoneAmplitudes[player] = 0; + int combinedAmp = 0; + for (int amp : playerPhoneAmplitudes) { + if (amp > combinedAmp) combinedAmp = amp; + } + try { + Vibrator phoneVibrator = (Vibrator) activity.getSystemService(Context.VIBRATOR_SERVICE); + if (phoneVibrator != null) { + if (combinedAmp <= 0) { + phoneVibrator.cancel(); + } else { + // Other players are still rumbling — keep the phone vibrating at their level. + vibrateSingle(phoneVibrator, combinedAmp, DEVICE_RUMBLE_MS); + } + } + } catch (Exception e) { + Log.e(TAG, "Error cancelling device vibration", e); + } + } } + /** Broadcasts the current Player 1 gamepad state to all connected gamepad clients. */ public void sendGamepadState() { if (!this.initReceived || this.gamepadClients.isEmpty()) { return; @@ -715,16 +1097,13 @@ private int resolveControllerSlot(int deviceId) { ExternalController controller = getControllerForSlot(slot); if (controller == null || controller.getDeviceId() != deviceId) { - ExternalController adopted = null; - if (inputControlsView != null) { + ExternalController adopted = ExternalController.getController(deviceId); + if (adopted == null && inputControlsView != null) { ControlsProfile profile = inputControlsView.getProfile(); if (profile != null) { adopted = profile.getController(deviceId); } } - if (adopted == null) { - adopted = ExternalController.getController(deviceId); - } if (adopted != null) { setControllerForSlot(slot, adopted); Timber.d("WinHandler: adopted controller %s(#%d) to slot %d", adopted.getName(), adopted.getDeviceId(), slot); @@ -733,6 +1112,7 @@ private int resolveControllerSlot(int deviceId) { return slot; } + /** Handles joystick/gamepad motion events, routing them to the correct player slot. */ public boolean onGenericMotionEvent(MotionEvent event) { if (!ExternalController.isJoystickDevice(event)) return false; @@ -749,6 +1129,7 @@ public boolean onGenericMotionEvent(MotionEvent event) { return false; } + /** Handles controller button press/release events, routing them to the correct player slot. */ public boolean onKeyEvent(KeyEvent event) { InputDevice device = event.getDevice(); if (device == null || !ExternalController.isGameController(device) || event.getRepeatCount() != 0) { @@ -780,11 +1161,13 @@ public void setPreferredInputApi(PreferredInputApi preferredInputApi) { this.preferredInputApi = preferredInputApi; } + /** Returns the ExternalController assigned to Player 1. */ public ExternalController getCurrentController() { return this.currentController; } + /** Writes a controller's current gamepad state into the shared-memory buffer for evshim consumption. */ public void sendMemoryFileState(ExternalController controller, MappedByteBuffer buffer) { if (buffer == null || controller == null) { return; @@ -826,6 +1209,7 @@ public void sendMemoryFileState(ExternalController controller, MappedByteBuffer buffer.put((byte)0); // Ignored HAT value } + /** Writes a virtual on-screen gamepad's state into the Player 1 shared-memory buffer. */ public void sendVirtualGamepadState(GamepadState state) { if (gamepadBuffer == null || state == null) { return; @@ -866,6 +1250,7 @@ public void sendVirtualGamepadState(GamepadState state) { gamepadBuffer.put((byte)0); // Ignored HAT value } + /** Populates per-slot ExternalController references from the saved ControllerManager assignments. */ private void initializeAssignedControllers() { Log.d(TAG, "Initializing controller assignments from saved settings..."); for (int i = 0; i < MAX_PLAYERS; i++) { @@ -886,6 +1271,7 @@ private void initializeAssignedControllers() { // This ensures P1-specific settings (like trigger type) are applied from preferences. refreshControllerMappings(); } + /** Clears the set of device IDs that were rejected from slot assignment. */ public void clearIgnoredDevices() { ignoredDeviceIds.clear(); } diff --git a/app/src/main/jniLibs/arm64-v8a/libevshim.so b/app/src/main/jniLibs/arm64-v8a/libevshim.so index 889871e61c660e0dd7d3b69e5d1c909307fec8f8..3c632512d414d971819ab61e25aae022fa7a22a8 100644 GIT binary patch literal 26288 zcmeHw3w%`7x$oM0Pm&=aA&~Hhg4q*5Ad)8`59BdPKmk!Apkfs#lgxw+Bs1yE1cR|U z0$N%*EmNfB_~M~$NfgUDVjFsBjc6;?_Fh1x-rJsQ1ZrcoHY#eOaB}|N+G|ZVn~Z(j z^Si&_{r%1gGi!bS^?hr7>s!ygX0OG)i%ia(A2QRx5nPl1BwzqgUEjx5vhA^b0bCN1hyX@=C;pMa4wXf;L? zMp4u8QSrG9p9%OVAkgL`YI=mn^*npizwFuFF>1qaAGy1B*I&jsuYc_Bj@-12eDKnT z?IhYF~NdPo=jKn8f(BzF0 zVX9h(2-A3-C&GV=M|P1;m@&52k+|6>i%U0J1Tx^K;*%n1nj6TGt!W-4M`O$ge6An@ zj;3##E2v&4;-lk3jxQrzuKG5&%N_Q%`NGT~8hn0tlkYAs^E7#b!9WmxpvCKl7uwhi z9&dANxGCgX>kYfy9#7C43K19#`UA`z4)|DoQ=oo5MTdROUZO%sswL?2hZ`7BuYUu1 zEtsW!{y^B*u#u8+-RTb3H^va3-xn@ozCeAri8Xth>l=fN>p)+nC9EaT)WlkXUbly} zgd53s)#E$tjgcO2*p2jLEL8Tkxkxb!)ap0DJA*z*^Azc;$>(3sLVlrjC>->(wlH{x zK@jlQH@7f%T_6}{4MDG$HM^UT5ZAS3BY7cjnAIB~66BFF(*?b2eW9>7=&BDiHwXMN zmDYPVx;)+{*ytk2QZ%F-YVp?l8hrIJHW>60Ea0#AvL;`hrzt-aV9Sb^q_>K7rNqxs*M zWx{Fvk*?c>)3_$xF%xdK=W!EGnsD=ayvKyocq?762{(_MeI{Ivy(H6b!fA|& zIa*M{QT>WXCr;Z<(Nlt(@pqD$qc{Hgv)W1qRUM< zjg8VdP54X+!e4B{<(x#(H70zDiGQ^Tr*T)hS`#k17LRH);c{H3h!ztr$8^HmOt^Wj zikNVn7=_zv!p(E#b`vh=SQ6P~!p(E?9uqF-LgL?V!f8&Hj@A;1m_ct|y8KrCrkaJ7 zs}`-$Yo>biV5{Hn^RLyX*DP76PxZ{y#e6VtYG_8XF&3{}xXiUQurU<&)vsSplaLvx z4AU5Yvo9EKbvG?)yC`m@H!LC+yThy!c{I5n%13FDTdZ^JI1la&kpEfl#ZH_)Ql*}-MlU_Bg z%`J;Ic>Up!%!1Dh^hhe7zDt4&A*X&;FZ3q>Fn_CJfvc>J08p_9%XIN~5Q6Ks` zQEj@CifCin<|LJvsR=)>rk$Q=- zaJs1d#MW3QmAh$? zmZ8pE)fx^{ZMOsi^#vhsQ$s<6r(mjwiAub_a>&ZR;S16Z{G)7q(<7qs934@;# zKC4XAAG1BKRi#d<+@UUgt@8i;JLjal7vl4K@I~N*ukf+N#}mL;y~Fm9wo_e{{6^*L zg1;)|h9QEu`TBed{E5(Y7Xd=PlJbFk)lT&evk2*X2%jzB*WpW7g21ow*#?|H!&ap^ zAG1H6gmf0}w0&3Gk@!%;_tagAdL48*^+x5Jzcorg>Dge5r(qi^i^UyhtT=kX#xlD# z*QqV=ojm*OQ$%B4-fn+;d{kxZcoq2G)>vncX7Bt2e7&Qx&R=L(I?e~wj?dR>9sMp_ z$EUX>bevnB*l|H)(ZLrJPxK59_OT<$(LdYRL%PBaDa={Hv0bUrO)RqFJ>aN~4||h# zUxqv~d$sJ`w;_Y)yX}r`wx|xC=?Xji2k>z@&&a4lkE|dX_ShatWCPuq>=4N~^wj7X z;CMXWUYpDYdKi0-@Y58Ro*1n{yc6l21WzQr9@AG#(HQw9>^}0K|IO)!Yos$ zM1Lh^Mh_!{c#`Qd%Y>Ea5h*iv7#YNq%u{BWnIobM$~hCz(6VGH)hE+ojBmVPp_bGV9DT zyGBI)ps}^GeNX-{GKeRcHD;M@iBY%Ar)U@%#FI=7cp8(P1I!s&!ITB3_UAhX_Dp6` z3h$fjtaxIYv*O9CoE5yC>`&!wu^KYG9UdPYEk}5d$__1spFNU&b(_joW!u#VVdJV-*_GKZ7*u^-EHGCIdlnxNlC zU(P`I%}?)ntq186=0M(QzUmmZUhh@x!lzfWfe$t4 zfcVega~q6x4)m0Pr&{6lTiagRrGGY9#^V&{;a=y_}+Kup0gvs7y`%@?z+5En9gRA~pTcPoJu-bOwLVIe*g=#I@n-KX9^oVv=Dv;P?cHqno z229fXF1KwWf(DM~GkTF% zV_&8DsNbc6SA$K^UeOn+-!$HH&yLU31<~Ghja}1Kv^QWZvLJd+Q*%FojqXQ&cFaxL z3GAzGg{_k9qA}5E7f;M*W47Sq2-~CBJH(V48G}8;%uH@@k0AgF%Bshk5t5I;IX0oBpCC!mV5&HAbs|`@7Xcqh56AEUt`w* zbbTFmB)t4y=MK_gM&*L2eYA5x-`Lhg^{8WP`~-aOqOPcaKd(*f((kh$z`#*Lb8^+| zBhbHXeB7WuY=e&2$1vK?lu=O{UwAv(KT?!8EBB1b%h5TgC1V_21U(l*-)iVx1^p}0 zHh#IE%EQOaiu5Q;a2`-cbkDPAL9hGSft*x~5l^uJ#_n4&0r`E3@v|1?cpG|D(fETg zaU9u7?JqrtyvX*S!jIX0N?J^pZm!F?+>`Lfp*>)aD)Wy*=G}{A5@IsX5)OOl$P3%> z4{7+2uJ7VwUz8f1@hc7epM6#2mpf(e)`uOnOw?mm?h}xwKKB{g+${KWL>uczn&@K# z)OUD$n1Q*B@<2WGXyZFiKyL3Dv_G^x^tIeG7|VKreZkwD5r6Lv_F-i@Tebg(cXxCa z+|}{)Prlai%!2j~yOR9M3?=22Zz^f8Jf)0!#jcF#T%x3Ol5bCEhZ-+)4(!`}ds~k- z7JWFI_hF7baeEt$_k@!k9DDKhwn{5@=JqzX71P$V-DkxnuW8$B!WL&E-|98x=6pG} za!t9z%2&UpywZw=)|9)g*yc6m_gS$WYs&Xpu_xA)zhK3lT~q$H6?<__xuOi&fa~`f z^s{1Tpq~}{82VW;?T+&MtXRe!<$JBzh%#qZt zmjdtop|j&p1vgULd3D_*51f4E)1Q8{^!V>SLOXhOU48FIGrqR;k=zR_b}YHDhecWN1w_k?dJ(#y?PK+~eAD8p?cJTha^Y#xR^S900iCCkI@4)_X-~LkP zz`5lU7Li^Z`^Utz@3)KnH)1&MiXUC7|cqcpASfh-J%%e5R+41Uuce1qu$BbvZ z?;Uw`yU>IWaa`cEHxB*#M< z|Ip?R(Rv2!zUcd!8XbXkRwve3v=&>hsL^d$YdtlstfO;QDf&Wx=?DiKo$X*n9h=8@ zZDVXz$L5>+rz`dcZRm6RwVV49AMMb_^{Wc{6zoD{wRv5%9Qsbz%Ix#21#`ccSu7i!CbIgVlu{OqfZ4C10 zo-<^_9+bzh;n>{E5l1xk0it9>tXZOUCL0dIhP~6!_x}zX_G0ZwI$|70Su;E78A7fD zGmv&8#xe9yS_gjT-16&C-lfo;%DcE@P}9Wv<>wvP;@D364j-pror!XXPUCsw;N=)I zGCJoW9VgcK3*b|Ku1#czKTe@Ej`H=wh4!CypiG@4k1=oHG%RpcZ9g5m)~VnkB9Hc?{bKQ||Ceow4Y3;N-hkZ!;U@TgN&7dgRALV#16+ZMC$N)y`j)=-0 z#Aa5M?4=J59@=pQ!`e3U)eM&LU`WmUX6>fz{=I2rsPcLFrlb zqIDFNS?N)uIX36vO|T!8d4Y``PJqr751Svn(0+vL?$ECt_~bUVgKYO%J=T!W|83~1 zzz)doxt9kAzt@d@nQlCDS0i8MgudtBI)>lU`c|s!xy&Z&)?;Ch__~i3@UvoqDUexRC$j*oN zCbOuc?`yBUA@jH}cxbE*&y~RUspDTITSn5@gH_<^0uSj!ZTkBN??}sduonAH5#&$n z{LQcj+97XGpWIdjn^mJ-y~Nw24RyYoDeu)`jDSrBu)i>1gI>Ft`d$?<6?r~^I#j{8 z09Yk34LqXGP;D@3`|};?sGrgK*AArDRYY|)`&72By&*q5t{8Efj5to0g5GDM=YT$H zqUVD?ZKA6{t8;kTT>cu+IVO53=qeL^1L!&vy#n+m6MZx2eJ1)VppTm9J3yZ{(RHBJ ztB3M$0G(r^*MY7w(SFc%COQaulZoB{dY_5D3-nPFeGll3A)NeKLfhXME@)3 zO(y!sp!b>RpMpMWqJIwhw23|nTAe?X|1Ux3nCMqPSDEPl0lLmap9HzM}3WWMx!206Q66l%os!?PTBPh-(a4&yv`pNRr| z@*hTd`bC0LuM$y^EWt0|>yYmP$a3OYn>+cQ2=*4aLqF!OPn37HNdG?seMr!Qf|lhL zVt=#OU16~PxBp*b2h_))+4z5rmi<7s8?!8hudS}0qffu~hMQ*SCHWQkZF+HG(d@#a z;#vB1ys{O<`%UhUcSh`8ZGC!K(d^l!vrA^;hiv*~G2qvOvnnczN@vv-&Cu(c-1v!c zgYh%3UQ~qNPG{@W8^ht2(42w-xADGr{@OrbEqOkkKCG9QH5AP%n(b+*^SaCGi)NL%-NoKg&#cm7PhDNf>@x4H^75kD<#~SE#=z zjiAP%t2|w5*m8Y?j`*Z(n#baPSzo@C#LZ+nux1Kx;3_)5MW zU%<&rnXb+J9sH4rkkIld$VT=cWv!qGK^se@_e0XYj;}s^Mn&*7nQ6O-qv)p~w0}j8 z8h@)r!*2;pYo|1{<)mZb7ZePVo<%(U^Q2@kC8V7OgHlAL*RWm5lwt~|+m&*%#OU<| zRbU;@#nIF)x?s{(#Te45gZ#qfQ{P?8TB(VhsF>b ze;iTj00r3u$|8&A0QCixa})r+?Du4Z7gQd;*@%1sj4=IZ$Yzxh^c$6AH7tn*|8G=^ zWLeWGw#!KEYZSZ20C<>ELF9MIPqI&>)J9Nd?;8Aji9Zcr04NNeX~dI@FEvTy8vY{2 zlcaH;%b!OEN>DagC#4OiJh}+@#00P(6s?)Vcar*(upPsawf9BD?n%_>leMl|Vqt9e z6~{$PwsPZ$7+bbdqi#mD{-%h1g=VR&3M#>CMhOB)+_2wk+#Z+V`Gq1MrDQL^286Oo z<{BXkPI)vRDL8n8S;ZR+z#MlF?dN8SAvc){qth<}aB^-`YVk`7n^K3bM*vTux_TXf zsnZ1`wAVgXYVekGA>e7e>3OKd&=8#CV~UpuJlZk6Mu?n1;?tW<1PX1Wfy$r;JmV-- zQoccYO151HlT#jjg9;L;TL%UT-b$(Say>}=-zPGNS5=j5M1DZ|?h;v@M!xzUL6BYb zE>leISwVb;m=}<;sEVul1fGeJ45d|2&Llf^#jMRRhWfHmD+7=)YUO34mCihc`d03- zm6O`Yi+@uA=3zaP_dBp>il|ydDVwLf4`?%F@^yhTRzODrl&7d9)|d8`M<+o~!w^rA zA)bNgG(I@|gew zz`s#18liR9a-2=39x1uyVoo`71^B0U5cN}}HiZs5y@S9NTST(FnadtTw%r0fCR2eW z2Wak;pRJ^%9zxU@*y@UHu@oSbj<+;^h?xBX6l+eVyfmc?@O%u{4H$tKSR zkBp9qzI=r8y9QC~AU^Ae5NCxiiBO5qY&K>vqlv#tHr@&DiPUR)1>bh?DJPP_#e2-F zi03`#0|Z#^>w>EY4QU#9s4u4n#`0NOZm*ki32FoY(AlVK&@F>$%_6b30y}b`v~n= zEkL-UjU>37=8zGwW>NGpVif+$>j>>gE%AXfkHl{z?M7&QwH#w*hoNWU%<^Y|COBv? zF;)u|-yz@%%9)0aWZqcj{0bmJWH8Q`OyhHM1Yc~(d)JKd}re!Ihk`ow#DH%#i3Z+5V$kbSZo?2 z=>{?=4OgYWEM&>3Fa?GflSFk~WKtFOC^i=pmI?M4h!*o`uHw}5O6 zYn$DY+B(E2gIzJGUIw3!K{YbiEg&N~LBu?d2+7Rj>JgI=0juB$78};AtPuem628K` z@mOThR93iKhGp3lCi9F*cZ*Pu@L^aPT!xNA4w=cIJaag_CEhpq*~h|1TIWO%iEsyq z*1P0p4nYPm^nG3iV=VQ$2$l>%GFTRaYGiP3462vG>KIfbgEcXzUIt|Wp{LYA`YnPx zB>XR_t~SB)!&nA2GI%HkHOOFB462dAmoQb3Q&@ux9v9H?ravOmct4g_y$tpVNM#SC z%W!`TYhg0HKbB{`k@?VfrihvtW2uqBnK8&CgR^2#jSQB=Add`|$DkS+To!{oG8l?M zH8R*1gFG_0B?i^V;E@<~Nt2hQkt`}J-aS-SBlNToKNL%*Mh1T(AeC*lcZsk}YM5s8 zk}$PhuqLzEO8k;shvAh=?n`*zyfocowTAMS@SguWc%`1BguY+W%%=#JLg8bekhLr8 zZ+<$XSS}encU{7AiB49QFVRVM)$=i(R>+{- zE~8$&;*xrr3Dpf5J1>N+d?{runN0u&H*M`0Zi+4N> zt9(DSAV0r=V0`kh;ah-XE(qc{H$NfdDJ?3lYnbgRDQqb6R1}rFiyG=m%V$^A&n_u$ znAOlw5ZV|D2C$_{`TP9!O|2d;`x0g7)RB?x&=zlFAQaAPD=&4GmgM0?RBKz_T7PRy zAJR=G%#6d!`PkTFY%LD1pUvgt7Qn zWuwtPVv0@Gj@xvbq8)74c{m^8L+z^_jsoqrxeK*6y)>sl8%1&N*ybYc+wJ*9*sfI) z^~CuF2&h+;M%n$--Aj@5lr(aqH;a&qOd=Z7iz>gZRFF6^Kp3%y!GJR5}FD%R3OBHqlD|}XM!+(CI|!p zw)*e%`8|1IH_i%@Sk01Ze0$sKy)9vsAS9ZSiwpfnaFb`}5D>mu)1%Y&IKe)orMsTa{_!I5+%HvO zh}S3)T_nIm|F!+t?GDyA#(KE;a`2>HXC+MQ~0&Wiq1CKKk2iS~QHlgU1PpVJfUFsDGeIEeTWe%t++iS! zO@~b7tp!7sO@(y1aM6P|xB+dDj)#I4A~Cy&y9|c3FtRkRR}d{;XevZS5oOJo?==`o zg~}2(+CAy2%{CdsEx2Y13?cn`bn&#kw$SH z&8&DuaL~#yiUXsVyl+9qXYxQglfSD=91r(!>9PA4ByPN?FrH15fbb`CKQ(sWnv9-~ zfWn6Im5Z3GfvY&gChv2Q`@D+mkn z!)fcA+%Cgo-{!u;wpjT8S>W=$2f@zv3H)vg|2fdAn#%g88l19RGcdO+>ZNwL0XXs7 zXipDrqT#Wp;SX1jh8>bwx?BYcexn|f*^kVZG$78X$8n6+3P#}>^*ENvd*Ed3b38Db z$@efMPQR7XtvJ?tf1cy1Oy0l2NqoJ857qCJwAzKEA2iAR1|J1CrS%vU$8-YW-or4O>Q)t0Y2)x{a(;fivJ1zJ~ zv`@kpTkuI7A8HS>c}*4kjTU}-8l-d%TJSjnZ?WL?kU;g`E$UsSe}mv}6Z|w@kfSF) z;y-TTcLS$(^?shg*COiO4}7@(awl-Qy-2Q?4JpR%66r|XDIy+_>5HFsBLd$kaO-cK z`*`}}SXZrJ6yEoNlYJyE+w(zz9})Z#e^KDw7W_8?KPGT#pZ_iJK7q@AVdyiG{oWW5 zm~^F@7-z~2L>CdpyfBg-v+%#e(;vd6o>^pXbqw3HOe7$@RHSbidU(ubaqyes;PjTj zaOu$7Cd1+DIDYAUY4^tA|9TvJTO9o1IQX78IQ?ogoSyW%;Bfelad4a^9+ux3 z2fvo%m)>JXKbjAh?@e*=JL2HpIJiF!z99~NcO3k_IQZ5$__yQW-;IMm76<=6$5Wa0 zm)xHN&k;?*?45{%pN)g}$H7Np+!!u>mXA9=y!rB#I-iHG8Oir-^wYI(`Pfm(7jNNR zM7V+m+2{^6vV7y4Wbg!>k9Ymtlz<4eG$FlUfImOvdmCL1LEIkW!ov=WQ7j+#w1JDW z5+kJn8J3S*>3lBZQ97R*|8u4U(ewy<|iXZykCeZ17!7VT;KtV|Y1XAZ!z`{7@rEBV_ojJ{YdUWp92L z?t7`n>mJzb!B;VT&7!XoHl+<0!QeSB6mT`d^i5t|3J2=}3iw@~Cgb`%GvR8Yd+s1x z$o!u5F8*xe!q!F|9yAG%l|+^45|!yPDw9Lp{5*t>7sdmypqF_!gc^O#hL*D_{Zi7c zh%NVv8-ikwd>it^cp?&)C%KFVYPJ?n-(+6g6@(2F4>JnGsH*rCN6@9I$%VUuLafOh z3cG^*5+)bjwsaAIYnWJzi*A20ZX+_nvCDU2q`dgY0G7~$s}YpfMOkl$x;U0!CUr3+ zig__OboC6sbtqn>xR)kgG~GdE#F_4h;(I_19==E6GH&dl?Ftva!H0*4g9$@{G1S;B z_Ek3EawXJhi19#c3+fcLAA6LY(k7o~$^0@bca<)B(4FFu(e#WfqUf1hZfVgYdwywA zNx6(Po{0sgc!a-ZMdkI2T#Ig4=yIXq8n;P`<{Wh08N@YGQ18pGnlyI+FpAVIyDmn$ zxTDFqh4H_yrTqW*T1wZ#TW_emeo3`?e)~tmv|Pd~Sa=pOoZL^4zT8(3(Xr=QN#pDm zOTkCV%l!rE%$$ia;*l)7xYo#B93_bfw zdAa|&RmfZOv&wfs7H{swyV+GnG(%lzf@^d2FA4$yw+)UR^Im!X~l?t_Z&m& z%2>e++RFPmU@JzPznEw6zau+_L0-a(GtHM^#QM}iL%?bmYhIagAzO_d?9bu^OI{0`Ohr_hLo3n1p<~VxM1HhL;jdJfam~u4L-8{$nl%r knYUCC7wo#lkY6b?GRRA8eO(;+lv@pf9Gcur&XOGaPa1{3-T(jq literal 11712 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b22eb76741..e4a1dc7074 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -735,6 +735,14 @@ Auto-replace stick elements with dynamic joysticks Enable Mouse Enable Sticks + + + Vibration Mode + Vibration Intensity + Off + Controller + Device + Touch Gesture Settings Single Finger diff --git a/build-evshim.ps1 b/build-evshim.ps1 new file mode 100644 index 0000000000..a4150d4772 --- /dev/null +++ b/build-evshim.ps1 @@ -0,0 +1,60 @@ +# build-evshim.ps1 +# Recompiles evshim.c into libevshim.so using the Android NDK clang toolchain. +# Run from the project root after editing app/src/main/cpp/extras/evshim.c. +# After this script succeeds, run .\gradlew.bat assembleDebug to include the +# updated .so in the APK. + +$ErrorActionPreference = "Stop" + +# Match the NDK version pinned in app/build.gradle.kts. +$NDK_VERSION = "22.1.7171670" + +# Prefer explicit SDK root env vars; fall back to the Android Studio default. +if ($env:ANDROID_SDK_ROOT) { $SDK_ROOT = $env:ANDROID_SDK_ROOT } +elseif ($env:ANDROID_HOME) { $SDK_ROOT = $env:ANDROID_HOME } +else { $SDK_ROOT = "$env:LOCALAPPDATA\Android\Sdk" } + +if (-not $SDK_ROOT) { + Write-Error "Android SDK root could not be determined. Set ANDROID_SDK_ROOT or ANDROID_HOME." +} + +$NDK_ROOT = "$SDK_ROOT\ndk\$NDK_VERSION" +$CLANG = "$NDK_ROOT\toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android21-clang.cmd" + +$SRC = "app\src\main\cpp\extras\evshim.c" +$INCLUDES = "app\src\main\cpp\extras\sdl2_stub" +$OUT = "app\src\main\jniLibs\arm64-v8a\libevshim.so" + +if (-not (Test-Path $CLANG)) { + Write-Error "Clang not found at: $CLANG`nCheck that NDK $NDK_VERSION is installed." +} + +# Ensure the output directory exists (needed on a fresh clone). +$outDir = Split-Path -Parent $OUT +if (-not (Test-Path $outDir)) { + New-Item -ItemType Directory -Force -Path $outDir | Out-Null + Write-Host "Created output directory: $outDir" +} + +Write-Host "Compiling evshim.c with NDK $NDK_VERSION ..." + +# Use $clangArgs (not $args) - $args is a PowerShell automatic variable and +# assigning to it can interfere with parameter passing in some contexts. +$clangArgs = @( + "-shared", "-fPIC", "-O2", + "-I", $INCLUDES, + "-Wl,--as-needed", + "-ldl", + "-o", $OUT, + $SRC +) + +& $CLANG @clangArgs + +if ($LASTEXITCODE -ne 0) { + Write-Error "Compilation failed (exit $LASTEXITCODE)" +} + +Write-Host "OK -> $OUT" +Write-Host "" +Write-Host "Next: .\gradlew.bat assembleDebug" From e043d6e4055327351efcb98984ace9c22deed8a6 Mon Sep 17 00:00:00 2001 From: TideGear Date: Tue, 28 Apr 2026 23:44:33 -0700 Subject: [PATCH 2/6] fix: review fixes + dropped upstream improvements on the vibration path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bot review (PR #1214): - ContainerUtils.toContainerData / applyToContainer: normalize vibrationMode via PrefManager.normalizeVibrationModeInput and clamp vibrationIntensity to 0..100 at the read/write boundary. Stops a stale 'both' (or any other invalid value) carried over from the prior schema from propagating into ContainerData and back to disk. - ContainerData.Saver/restorer: same defence on the Compose Saver round-trip via a private VALID_RESTORED_VIBRATION_MODES whitelist plus an Int.coerceIn(0, 100) on intensity. Rebuilt invalid mode strings fall back to 'controller'. - WinHandler.startRumblePoller: re-check 'running' after acquiring rumbleNotifyLock so a stop() that runs between the outer while(running) check and the synchronized block is observed before the poller goes into wait(0). Without this, stop()'s notifyAll() is the only chance to wake the poller and a tight race could miss it. - WinHandler.rumbleViaVibratorManager: Arrays.sort(ids) instead of a two-element manual swap. Generalises the heavy/light motor selection to 3+ vibrator controllers (DualSense edition variants); 2-vibrator cases produce the same result as before. Broader review (dropped from upstream PR #1261 when copying pre-cleanup's WinHandler wholesale): - WinHandler GET_GAMEPAD adoption: when no current controller is set, consult ControllerManager.getAssignedDeviceForSlot(0) instead of ExternalController.getController(0). The latter queries InputDevice ID 0 — an arbitrary number that rarely corresponds to the user's slot-0 assignment. - WinHandler.start(): call initializeAssignedControllers() so saved slot assignments are pre-registered before the first GET_GAMEPAD packet arrives. Pre-cleanup defined the method but never called it (dead code); upstream's PR called it from start(), and games that probe controllers at startup were depending on that pre-registration. --- .../app/gamenative/utils/ContainerUtils.kt | 17 +++++++++--- .../com/winlator/container/ContainerData.kt | 9 +++++-- .../com/winlator/winhandler/WinHandler.java | 26 ++++++++++++++++--- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 1d9d872e0f..6e11d2af07 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -321,8 +321,11 @@ object ContainerUtils { sharpnessEffect = container.getExtra("sharpnessEffect", "None"), sharpnessLevel = container.getExtra("sharpnessLevel", "100").toIntOrNull() ?: 100, sharpnessDenoise = container.getExtra("sharpnessDenoise", "100").toIntOrNull() ?: 100, - vibrationMode = container.getExtra("vibrationMode", "controller"), - vibrationIntensity = container.getExtra("vibrationIntensity", "100").toIntOrNull() ?: 100, + vibrationMode = PrefManager.normalizeVibrationModeInput( + container.getExtra("vibrationMode", "controller"), + ), + vibrationIntensity = (container.getExtra("vibrationIntensity", "100").toIntOrNull() ?: 100) + .coerceIn(0, 100), ) } @@ -489,8 +492,14 @@ object ContainerUtils { container.putExtra("sharpnessEffect", containerData.sharpnessEffect) container.putExtra("sharpnessLevel", containerData.sharpnessLevel.toString()) container.putExtra("sharpnessDenoise", containerData.sharpnessDenoise.toString()) - container.putExtra("vibrationMode", containerData.vibrationMode) - container.putExtra("vibrationIntensity", containerData.vibrationIntensity.toString()) + container.putExtra( + "vibrationMode", + PrefManager.normalizeVibrationModeInput(containerData.vibrationMode), + ) + container.putExtra( + "vibrationIntensity", + containerData.vibrationIntensity.coerceIn(0, 100).toString(), + ) try { container.language = containerData.language } catch (e: Exception) { diff --git a/app/src/main/java/com/winlator/container/ContainerData.kt b/app/src/main/java/com/winlator/container/ContainerData.kt index b1b617c04a..24f5d49858 100644 --- a/app/src/main/java/com/winlator/container/ContainerData.kt +++ b/app/src/main/java/com/winlator/container/ContainerData.kt @@ -100,6 +100,8 @@ data class ContainerData( val sharpnessDenoise: Int = 100, ) { companion object { + private val VALID_RESTORED_VIBRATION_MODES = setOf("off", "controller", "device") + val Saver = mapSaver( save = { state -> mapOf( @@ -182,8 +184,11 @@ data class ContainerData( executablePath = savedMap["executablePath"] as String, installPath = savedMap["installPath"] as String, showFPS = savedMap["showFPS"] as Boolean, - vibrationMode = (savedMap["vibrationMode"] as? String) ?: "controller", - vibrationIntensity = (savedMap["vibrationIntensity"] as? Int) ?: 100, + vibrationMode = (savedMap["vibrationMode"] as? String) + ?.trim()?.lowercase() + ?.takeIf { it in VALID_RESTORED_VIBRATION_MODES } + ?: "controller", + vibrationIntensity = ((savedMap["vibrationIntensity"] as? Int) ?: 100).coerceIn(0, 100), launchRealSteam = savedMap["launchRealSteam"] as Boolean, allowSteamUpdates = savedMap["allowSteamUpdates"] as Boolean, steamType = (savedMap["steamType"] as? String) ?: "normal", diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index 862cdd4fd9..1920261a72 100644 --- a/app/src/main/java/com/winlator/winhandler/WinHandler.java +++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java @@ -505,7 +505,14 @@ private void handleRequest(byte requestCode, final int port) throws IOException final boolean useVirtualGamepad = inputControlsView != null && profile != null && profile.isVirtualGamepad(); int processId = this.receiveData.getInt(); if (!useVirtualGamepad && ((externalController = this.currentController) == null || !externalController.isConnected())) { - this.currentController = ExternalController.getController(0); + // Use ControllerManager as the single source of truth for slot 0 + // rather than ExternalController.getController(0) which queries + // InputDevice ID 0 — that ID is arbitrary and rarely matches the + // user's slot-0 assignment. + InputDevice p1Device = controllerManager.getAssignedDeviceForSlot(0); + if (p1Device != null) { + this.currentController = ExternalController.getController(p1Device.getId()); + } } boolean enabled2 = this.currentController != null || useVirtualGamepad; if (enabled2) { @@ -666,6 +673,11 @@ public void start() { } this.running = true; startSendThread(); + + // Pre-register controllers from saved slot assignments so games that probe + // for controllers at startup see them before the first input arrives. + initializeAssignedControllers(); + Executors.newSingleThreadExecutor().execute(() -> { try { DatagramSocket datagramSocket = new DatagramSocket((SocketAddress) null); @@ -780,6 +792,11 @@ private void startRumblePoller() { if (waitMs <= 0) waitMs = 1; // never spin; ensure we yield to other threads try { synchronized (rumbleNotifyLock) { + // Re-check running under the lock so a stop() that runs between the + // outer while(running) check and this synchronized block is observed. + // Without this, the poller would call wait(0) and miss the only + // notify() the stop() path emits. + if (!running) break; // wait(0) means indefinite; cap at keepalive interval when rumbling. rumbleNotifyLock.wait(waitMs == Long.MAX_VALUE ? 0 : waitMs); } @@ -841,13 +858,14 @@ private boolean rumbleViaVibratorManager(VibratorManager vm, short lowFreq, shor int lowAmp = scaleAmplitude(lowFreq, vibrationIntensity); if (lowAmp == 0 && highAmp == 0) { vm.cancel(); return true; } - // Determine which ID drives the low-freq (heavy/left) motor and which drives - // the high-freq (light/right) motor by sorting IDs ascending. + // Sort IDs ascending so the low-freq (heavy/left) and high-freq (light/right) + // selection is well-defined regardless of the order getVibratorIds() returned. + // Manual two-element swap was correct for 2 IDs; sort generalises to 3+. + Arrays.sort(ids); int lowMotorId = ids[0]; int highMotorId = ids.length >= 2 ? ids[1] : ids[0]; if (ids.length >= 2) { - if (ids[0] > ids[1]) { lowMotorId = ids[1]; highMotorId = ids[0]; } String motorKey = lowMotorId + "_" + highMotorId; if (loggedRumbleMotorIds.add(motorKey)) { Log.d(TAG, "Rumble motors: lowMotor=" + lowMotorId + " highMotor=" + highMotorId); From 85eab6d745bf45e6a7d0826302368242949f952a Mon Sep 17 00:00:00 2001 From: TideGear Date: Wed, 29 Apr 2026 18:27:12 -0700 Subject: [PATCH 3/6] fix: cap evshim's vjoy count to currently-connected controllers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EVSHIM_MAX_PLAYERS was hardcoded to WinHandler.MAX_PLAYERS (=4), so evshim always registered four SDL virtual joysticks regardless of how many physical controllers were actually connected. Games (ToS, etc.) would see one or more phantom unbound gamepads — they didn't respond to input because no slot had a controller, and rumble routed at them went nowhere because getControllerForSlot returned null. Cap to controllerManager.getDetectedDevices().size() with a floor of 1 so the virtual on-screen gamepad still has a vjoy when no physical controller is present at launch. Mem files for all four slots are still pre-created; only the SDL vjoy registration is bounded. --- .../components/BionicProgramLauncherComponent.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java index ee962f777f..00ecf872f0 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java @@ -181,8 +181,7 @@ private int execGuestProgram() { // Always pre-create all 4 mem files so controllers can be hot-plugged during gameplay. // Unused gamepads just read zeroes (no-op in evshim). - final int enabledPlayerCount = WinHandler.MAX_PLAYERS; - for (int i = 0; i < enabledPlayerCount; i++) { + for (int i = 0; i < WinHandler.MAX_PLAYERS; i++) { String memPath; if (i == 0) { // Player 1 uses the original, non-numbered path that is known to work. @@ -223,7 +222,16 @@ private int execGuestProgram() { EnvVars envVars = new EnvVars(); - // Use the ControllerManager's dynamic count for the environment variable + // Tell evshim how many SDL virtual joysticks to register, capped at the count of + // currently-detected physical controllers. Without the cap we'd always spawn + // MAX_PLAYERS vjoys regardless of how many were connected, and games (e.g. ToS + // controller tester) would see phantom unbound gamepads — they don't respond to + // input and rumble routed at them goes nowhere. Floor at 1 so the virtual + // on-screen gamepad still has a vjoy when no physical controller is present. + final int connectedControllerCount = + com.winlator.inputcontrols.ControllerManager.getInstance().getDetectedDevices().size(); + final int enabledPlayerCount = + Math.max(1, Math.min(connectedControllerCount, WinHandler.MAX_PLAYERS)); envVars.put("EVSHIM_MAX_PLAYERS", String.valueOf(enabledPlayerCount)); if (true) { envVars.put("EVSHIM_SHM_ID", 1); From 06906b80d089665e96712789526473eb46617b50 Mon Sep 17 00:00:00 2001 From: TideGear Date: Wed, 13 May 2026 15:24:08 -0700 Subject: [PATCH 4/6] fix: redeploy libevshim.so from APK and restore multi-controller fixes dropped during merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The libevshim.so baked into imagefs_bionic.txz has hardcoded /data/data/com.winlator.cmod/... gamepad-mem paths from before the package rename to app.gamenative. Every open() fails, OnRumble bails before writing, and the FileObserver/poller chain never wakes. Input still works because xinput uses the UDP path (winhandler.exe <-> WinHandler.java), which is independent of evshim — so the regression presented as silent rumble only. Re-add the APK->imagefs copy block (originally in b8d7a49a, removed during PR review) so the freshly built jniLibs/arm64-v8a/libevshim.so (built from evshim.c via build-evshim.ps1) overwrites the stale imagefs copy on every launch. Gate the LD_PRELOAD entry on the file existing so a copy failure doesn't poison every spawned process. Also enable EVSHIM_DEBUG=1 to surface the per-rumble logs. Restore three multi-controller fixes that the upstream/master merge dropped: * WinHandler.setControllerForSlot: actually store the new controller for the slot. Without these two lines getControllerForSlot returns null and the rumble poller has no device to vibrate. * WinHandler.onKeyEvent: replace the wildcard-only adoption flow with the resolveControllerSlot-based slot routing so P2-P4 inputs reach the correct gamepad{N}.mem buffer. * PhysicalControllerHandler.handleInputEvent: real-device-first adoption with profile wildcard as fallback. Inverting this adopts the wildcard ExternalController (id == "*") for every physical device, collapsing all slots onto one shared instance (bllendev's PR #1261 regression). Add a 'Rumble buf P%d low=... high=... mode=...' diagnostic in the rumble poller so future regressions can be triaged in logcat without rebuilding. --- .../xserver/PhysicalControllerHandler.kt | 9 ++- .../com/winlator/winhandler/WinHandler.java | 61 ++++++++----------- .../BionicProgramLauncherComponent.java | 29 ++++++++- 3 files changed, 57 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt index 2498bf000b..f7a651a373 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt @@ -256,11 +256,14 @@ class PhysicalControllerHandler( val slot = if (deviceId >= 0) controllerManager.autoAssignDevice(deviceId) else 0 if (slot < 0) return - // Ensure we have a controller in this slot + // Ensure we have a controller in this slot. + // Real device first; profile (wildcard) fallback. Inverting this + // adopts the wildcard ExternalController (id == "*") for every + // physical device, collapsing all slots onto one shared instance. var slotController = winHandler.getControllerForSlot(slot) if (slotController == null || (deviceId >= 0 && slotController.deviceId != deviceId)) { - val adopted = profile?.getController(deviceId) - ?: ExternalController.getController(deviceId) + val adopted = ExternalController.getController(deviceId) + ?: profile?.getController(deviceId) ?: return winHandler.setControllerForSlot(slot, adopted) slotController = adopted diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index f25f99fa49..05925e08f0 100644 --- a/app/src/main/java/com/winlator/winhandler/WinHandler.java +++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java @@ -251,6 +251,11 @@ public void setControllerForSlot(int slot, ExternalController controller) { buf.putShort(34, (short) 0); } } + // Actually store the new controller. This was dropped during the + // upstream/master merge resolution; without it getControllerForSlot + // always returns null and the rumble poller has no device to vibrate. + if (slot == 0) { currentController = controller; return; } + if (slot > 0 && slot <= extraControllers.length) extraControllers[slot - 1] = controller; } private boolean sendPacket(int port) { @@ -754,6 +759,12 @@ private void startRumblePoller() { short highFreq = buf.getShort(34); boolean changed = lowFreq != lastLowFreqs[p] || highFreq != lastHighFreqs[p]; if (changed) { + // Surface every buffer transition. Diagnostic for missing rumble: + // if this never fires for a slot the game/SDL never wrote bytes; + // if it fires but the controller doesn't vibrate, look at + // resolveInputDeviceForPlayer / VibratorManager downstream. + Log.i(TAG, "Rumble buf P" + p + " low=" + (lowFreq & 0xffff) + + " high=" + (highFreq & 0xffff) + " mode=" + vibrationMode); lastLowFreqs[p] = lowFreq; lastHighFreqs[p] = highFreq; lastKeepaliveMs[p] = now; @@ -1152,47 +1163,25 @@ public boolean onGenericMotionEvent(MotionEvent event) { /** Handles controller button press/release events, routing them to the correct player slot. */ public boolean onKeyEvent(KeyEvent event) { - MappedByteBuffer buffer = null; - boolean handled = false; - ExternalController externalController = this.currentController; - buffer = gamepadBuffer; - // If this is a gamepad event but our controller is null or mismatched, adopt it InputDevice device = event.getDevice(); - if ((externalController == null || externalController.getDeviceId() != event.getDeviceId()) - && device != null && ExternalController.isGameController(device) - && event.getRepeatCount() == 0) { - ExternalController adopted = null; - // Try to get controller from profile first (has saved bindings) - if (inputControlsView != null) { - ControlsProfile profile = inputControlsView.getProfile(); - if (profile != null) { - adopted = profile.getController(event.getDeviceId()); - } - } - // Fallback to creating new controller if profile doesn't have one - if (adopted == null) { - adopted = ExternalController.getController(event.getDeviceId()); - } - if (adopted != null && "*".equals(adopted.getId())) { - this.currentController = adopted; - externalController = adopted; - Timber.d("WinHandler.onKeyEvent: adopted controller %s(#%d)", adopted.getName(), adopted.getDeviceId()); - } + if (device == null || !ExternalController.isGameController(device) || event.getRepeatCount() != 0) { + return false; } + int slot = resolveControllerSlot(event.getDeviceId()); + if (slot < 0) return false; - if (externalController != null && externalController.getDeviceId() == event.getDeviceId() && event.getRepeatCount() == 0) { - int action = event.getAction(); - if (action == KeyEvent.ACTION_DOWN) { - handled = this.currentController.updateStateFromKeyEvent(event); - } else if (action == KeyEvent.ACTION_UP) { - handled = this.currentController.updateStateFromKeyEvent(event); - } - sendMemoryFileState(this.currentController, buffer); - if (handled) { - sendGamepadState(); - } + ExternalController controller = getControllerForSlot(slot); + if (controller == null) return false; + + boolean handled = false; + int action = event.getAction(); + if (action == KeyEvent.ACTION_DOWN || action == KeyEvent.ACTION_UP) { + handled = controller.updateStateFromKeyEvent(event); } + MappedByteBuffer buffer = getBufferForSlot(slot); + if (buffer != null) sendMemoryFileState(controller, buffer); + if (handled && slot == 0) sendGamepadState(); return handled; } diff --git a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java index 820f7d0080..de93d0eab8 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java @@ -234,6 +234,10 @@ private int execGuestProgram() { final int enabledPlayerCount = Math.max(1, Math.min(connectedControllerCount, WinHandler.MAX_PLAYERS)); envVars.put("EVSHIM_MAX_PLAYERS", String.valueOf(enabledPlayerCount)); + // Surface evshim's "Rumble P%d" debug prints in logcat so we can see + // when the game writes rumble bytes to gamepad{,1,2,3}.mem. Gated on + // EVSHIM_DEBUG so it stays off in non-debug builds. + envVars.put("EVSHIM_DEBUG", "1"); if (true) { envVars.put("EVSHIM_SHM_ID", 1); } @@ -301,11 +305,30 @@ private int execGuestProgram() { String evshimPath = imageFs.getLibDir() + "/libevshim.so"; String replacePath = imageFs.getLibDir() + "/libredirect-bionic.so"; - if (new File(sysvPath).exists()) ld_preload += sysvPath; + // The libevshim.so baked into imagefs_bionic.txz is stale: its gamepad-mem + // paths still point at the legacy com.winlator.cmod package, so open() fails + // and OnRumble never writes. Overwrite it on every launch with the freshly + // built copy bundled in the APK's jniLibs (built from app/src/main/cpp/extras/evshim.c + // via build-evshim.ps1) so evshim.c source changes propagate to the runtime. + File apkEvshim = new File(context.getApplicationInfo().nativeLibraryDir, "libevshim.so"); + File ifsEvshim = new File(evshimPath); + if (apkEvshim.exists()) { + if (FileUtils.copy(apkEvshim, ifsEvshim)) { + FileUtils.chmod(ifsEvshim, 0755); + Log.i("EvshimDeploy", "Copied APK evshim -> " + evshimPath); + } else { + Log.e("EvshimDeploy", "Failed to copy APK evshim to " + evshimPath); + } + } + if (new File(sysvPath).exists()) ld_preload += sysvPath; - ld_preload += ":" + evshimPath; - ld_preload += ":" + replacePath; + if (ifsEvshim.exists()) { + ld_preload += (ld_preload.isEmpty() ? "" : ":") + evshimPath; + } else { + Log.w("EvshimDeploy", "evshim not present at " + evshimPath + "; skipping LD_PRELOAD entry"); + } + ld_preload += (ld_preload.isEmpty() ? "" : ":") + replacePath; envVars.put("LD_PRELOAD", ld_preload); From 1fc7318415c115f22a79660edfe153b271cc9372 Mon Sep 17 00:00:00 2001 From: TideGear Date: Wed, 13 May 2026 15:46:50 -0700 Subject: [PATCH 5/6] fix: address two review findings in WinHandler GET_GAMEPAD_STATE NPE: the addAction lambda captured enabled3 from a snapshot of this.currentController taken before the deviceId-mismatch check on line ~613 that nulls this.currentController. With enabled3 still true the lambda would then dereference the cleared field on `this.currentController.state.writeTo`. Same NPE class also reachable from a concurrent RELEASE_GAMEPAD on another thread. Null the local snapshot alongside the field in the mismatch branch, capture into a final local after, recompute enabled3 from the captured value, and have the lambda use the capture. If captured ends up null with no virtual gamepad, enabled3 is false and the writeTo call is skipped cleanly. stop() poller leak: stop() flipped running=false and notifyAll'd on rumbleNotifyLock but returned before the rumblePollerThread had actually exited. A subsequent start() that flipped running=true again would resurrect the old poller, and the new FileObservers would overwrite the slots without the old ones ever getting stopWatching()'d. Join the poller with a 1s timeout after the notifyAll so stop() is contractually complete on return. Guard against self-join from the poller thread. start()-side defensive check the reviewer also raised is intentionally not added: the only call pattern (one start per launch, one stop at exit, followed by PluviaApp.shutdownEnvironment) doesn't reuse the instance, and with stop() now blocking until the poller exits a properly-ordered stop()->start() is race-free. Adding a second layer would be untested code. --- .../com/winlator/winhandler/WinHandler.java | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index 05925e08f0..3d625426b1 100644 --- a/app/src/main/java/com/winlator/winhandler/WinHandler.java +++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java @@ -462,6 +462,18 @@ public void stop() { synchronized (rumbleNotifyLock) { rumbleNotifyLock.notifyAll(); } + // Block until the poller has fully exited so its FileObservers get stopWatching()'d + // before we return. Without this join, a subsequent start() that flips running=true + // again would resurrect the old poller alongside the new one. Bounded timeout so a + // wedged poller can't hang exit; guard against self-join from the poller thread. + Thread poller = this.rumblePollerThread; + if (poller != null && poller != Thread.currentThread()) { + try { + poller.join(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } for (int p = 0; p < MAX_PLAYERS; p++) { stopVibrationForPlayer(p); } @@ -609,10 +621,15 @@ private void handleRequest(byte requestCode, final int port) throws IOException final ControlsProfile profile2 = inputControlsView.getProfile(); final boolean useVirtualGamepad2 = inputControlsView != null && profile2 != null && profile2.isVirtualGamepad(); ExternalController externalController2 = this.currentController; - final boolean enabled3 = externalController2 != null || useVirtualGamepad2; if (externalController2 != null && externalController2.getDeviceId() != gamepadId) { this.currentController = null; + externalController2 = null; } + // Capture into a final local after the possible null-out above so the lambda + // doesn't dereference a this.currentController that another thread (or this + // request itself) cleared between snapshot and lambda execution. + final ExternalController capturedController = externalController2; + final boolean enabled3 = capturedController != null || useVirtualGamepad2; addAction(() -> { sendData.rewind(); sendData.put(RequestCodes.GET_GAMEPAD_STATE); @@ -622,7 +639,7 @@ private void handleRequest(byte requestCode, final int port) throws IOException if (useVirtualGamepad2) { inputControlsView.getProfile().getGamepadState().writeTo(this.sendData); } else { - this.currentController.state.writeTo(this.sendData); + capturedController.state.writeTo(this.sendData); } } sendPacket(port); From ea26c9a0d40c679b19da8726871eff30731f0190 Mon Sep 17 00:00:00 2001 From: TideGear Date: Wed, 13 May 2026 16:02:55 -0700 Subject: [PATCH 6/6] fix: reconcile active rumble on vibration setting change; capture GET_GAMEPAD lambda values Vibration setting reconciliation: setVibrationMode and setVibrationIntensity used to just update the volatile field. Two visible bugs followed: switching away from "device" mode left the 60s phone one-shot (DEVICE_RUMBLE_MS) running until its timer expired, because stopVibrationForPlayer gates its phone-cancel branch on the current mode and the field had already flipped. And an intensity change in "device" mode took up to ~55s to take effect (DEVICE_RUMBLE_REFRESH_MS) because that's how often the long device one-shot is re-issued. Both setters now early-return on a no-op, otherwise call a new reconcileActiveRumble() helper after updating the field. The helper cancels the handset vibrator unconditionally (sidestepping the mode gate), calls stopVibrationForPlayer for every active slot, zeroes playerPhoneAmplitudes and lastLow/HighFreqs, and notifyAll's the poller. The next poller tick reads the current buffer state as a transition and re-fires startVibrationForPlayer under the new settings. GET_GAMEPAD lambda NPE: same class of bug as the GET_GAMEPAD_STATE one fixed in the prior commit. The addAction lambda dereferenced this.currentController and inputControlsView.getProfile() at execution time on the send thread, both of which can be cleared/reassigned by RELEASE_GAMEPAD or other handlers between queue and execution. Capture finalDeviceId, finalMapperType, and finalNameBytes into final locals before addAction so the lambda references stable snapshots. Truncation logic moved out of the lambda - bytes copy happens once at queue time. Skipped the parallel addAction at lines ~640 with the same code shape: its finalEnabled2 flag is captured from `enabled = enabled2 = false` on that branch, so the controller dereferences live inside `if (finalEnabled2)` which is always false at lambda runtime - dead code, no real NPE path. --- .../com/winlator/winhandler/WinHandler.java | 88 +++++++++++++++---- 1 file changed, 70 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index 3d625426b1..dae8e616f8 100644 --- a/app/src/main/java/com/winlator/winhandler/WinHandler.java +++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java @@ -139,17 +139,55 @@ public void setInputControlsView(InputControlsView view) { /** Sets the vibration routing mode (off/controller/device), normalizing and validating input. */ public void setVibrationMode(String mode) { + String newMode; if (mode == null) { - this.vibrationMode = DEFAULT_VIBRATION_MODE; + newMode = DEFAULT_VIBRATION_MODE; } else { String normalized = mode.trim().toLowerCase(Locale.US); - this.vibrationMode = VALID_VIBRATION_MODES.contains(normalized) ? normalized : DEFAULT_VIBRATION_MODE; + newMode = VALID_VIBRATION_MODES.contains(normalized) ? normalized : DEFAULT_VIBRATION_MODE; } + if (newMode.equals(this.vibrationMode)) return; + this.vibrationMode = newMode; + reconcileActiveRumble(); } /** Sets the vibration intensity percentage, clamped to 0–100. */ public void setVibrationIntensity(int intensity) { - this.vibrationIntensity = Math.max(0, Math.min(100, intensity)); + int clamped = Math.max(0, Math.min(100, intensity)); + if (clamped == this.vibrationIntensity) return; + this.vibrationIntensity = clamped; + reconcileActiveRumble(); + } + + /** + * Cancel any in-flight vibration and force the rumble poller to re-evaluate + * every slot under the new vibrationMode/vibrationIntensity on its next tick. + * Without this, a switch away from "device" mode would leave the 60s phone + * one-shot running until its timer expired, and an intensity change in + * "device" mode would not take effect for ~55s (the long device refresh). + * The handset vibrator is cancelled unconditionally because + * stopVibrationForPlayer gates its phone-cancel branch on the current mode, + * which has already been updated by the time we get here. + */ + private void reconcileActiveRumble() { + try { + Vibrator phoneVibrator = (Vibrator) activity.getSystemService(Context.VIBRATOR_SERVICE); + if (phoneVibrator != null) phoneVibrator.cancel(); + } catch (Exception e) { + Log.e(TAG, "reconcileActiveRumble: phone vibrator cancel failed", e); + } + for (int p = 0; p < MAX_PLAYERS; p++) { + if (isRumbling[p]) stopVibrationForPlayer(p); + playerPhoneAmplitudes[p] = 0; + // Zero last-seen frequencies so the next poller tick sees the current + // buffer as a transition and re-fires startVibrationForPlayer with the + // new mode/intensity. + lastLowFreqs[p] = 0; + lastHighFreqs[p] = 0; + } + synchronized (rumbleNotifyLock) { + rumbleNotifyLock.notifyAll(); + } } public enum PreferredInputApi { @@ -566,25 +604,39 @@ private void handleRequest(byte requestCode, final int port) throws IOException this.gamepadClients.remove(Integer.valueOf(port)); } final boolean finalEnabled = enabled; + // Capture every value the lambda needs into final locals at queue-time. + // this.currentController and inputControlsView.getProfile() can be + // cleared or reassigned by RELEASE_GAMEPAD / other concurrent handlers + // between when this is queued and when the send thread runs it. + final int finalDeviceId; + final byte finalMapperType; + final byte[] finalNameBytes; + if (finalEnabled) { + finalDeviceId = !useVirtualGamepad ? this.currentController.getDeviceId() : profile.id; + finalMapperType = this.dinputMapperType; + String capturedName = useVirtualGamepad ? profile.getName() : this.currentController.getName(); + byte[] originalBytes = capturedName.getBytes(); + final int MAX_NAME_LENGTH = 54; + if (originalBytes.length > MAX_NAME_LENGTH) { + Log.w("WinHandler", "Controller name is too long ("+originalBytes.length+" bytes), truncating: "+capturedName); + finalNameBytes = new byte[MAX_NAME_LENGTH]; + System.arraycopy(originalBytes, 0, finalNameBytes, 0, MAX_NAME_LENGTH); + } else { + finalNameBytes = originalBytes; + } + } else { + finalDeviceId = 0; + finalMapperType = 0; + finalNameBytes = null; + } addAction(() -> { this.sendData.rewind(); this.sendData.put((byte) RequestCodes.GET_GAMEPAD); if (finalEnabled) { - this.sendData.putInt(!useVirtualGamepad ? this.currentController.getDeviceId() : profile.id); - this.sendData.put(this.dinputMapperType); - String originalName = (useVirtualGamepad ? profile.getName() : currentController.getName()); - byte[] originalBytes = originalName.getBytes(); - final int MAX_NAME_LENGTH = 54; - byte[] bytesToWrite; - if (originalBytes.length > MAX_NAME_LENGTH) { - Log.w("WinHandler", "Controller name is too long ("+originalBytes.length+" bytes), truncating: "+originalName); - bytesToWrite = new byte[MAX_NAME_LENGTH]; - System.arraycopy(originalBytes, 0, bytesToWrite, 0, MAX_NAME_LENGTH); - } else { - bytesToWrite = originalBytes; - } - sendData.putInt(bytesToWrite.length); - sendData.put(bytesToWrite); + this.sendData.putInt(finalDeviceId); + this.sendData.put(finalMapperType); + sendData.putInt(finalNameBytes.length); + sendData.put(finalNameBytes); } else { this.sendData.putInt(0); this.sendData.put((byte) 0);