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 951ce9060e..47b1832378 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/PhysicalControllerHandler.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/PhysicalControllerHandler.kt index a18c8f2d99..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 @@ -7,6 +7,7 @@ import android.view.KeyEvent import android.view.MotionEvent import com.winlator.inputcontrols.Binding import com.winlator.inputcontrols.ControlElement +import com.winlator.inputcontrols.ControllerManager import com.winlator.inputcontrols.ControlsProfile import com.winlator.inputcontrols.ExternalController import com.winlator.inputcontrols.ExternalControllerBinding @@ -28,18 +29,23 @@ class PhysicalControllerHandler( private val TAG = "gncontrol" private val mouseMoveOffset = PointF(0f, 0f) private var mouseMoveTimer: Timer? = null - // track which axis keycodes are currently "pressed" so we only release on actual transitions. + // track which axis keycodes are currently "pressed" per device so we only release on actual + // transitions for that device. axis keycodes are device-agnostic (left-stick-left is the same + // Int on P1 and P2), so a global set conflates state and can strand a press when one device + // releases while another is still holding the same direction. // accessed only from main thread (MotionEvent dispatch + Compose lifecycle), no sync needed. - private val activeAxisBindings = mutableSetOf() + private val activeAxisBindings = mutableMapOf>() // Tracks whether SHOW_KEYBOARD is currently held, so onShowKeyboard fires once per press (rising edge only) private var showKeyboardPressed = false private fun releaseActiveAxes() { val controller = profile?.getController("*") ?: return - for (keyCode in activeAxisBindings) { - controller.getControllerBinding(keyCode)?.let { - handleInputEvent(it.binding, false, 0f) + for ((deviceId, keyCodes) in activeAxisBindings) { + for (keyCode in keyCodes) { + controller.getControllerBinding(keyCode)?.let { + handleInputEvent(it.binding, false, 0f, deviceId) + } } } activeAxisBindings.clear() @@ -91,7 +97,7 @@ class PhysicalControllerHandler( val offset = if (event.action == KeyEvent.ACTION_DOWN && (controllerBinding.binding == Binding.GAMEPAD_BUTTON_L2 || controllerBinding.binding == Binding.GAMEPAD_BUTTON_R2) ) 1f else 0f - handleInputEvent(controllerBinding.binding, event.action == KeyEvent.ACTION_DOWN, offset) + handleInputEvent(controllerBinding.binding, event.action == KeyEvent.ACTION_DOWN, offset, event.deviceId) return true } } @@ -124,13 +130,16 @@ class PhysicalControllerHandler( if (profile != null) { val controller = profile?.getController(event.deviceId) if (controller != null && controller.updateStateFromMotionEvent(event)) { + val deviceId = event.deviceId + // Process trigger buttons (L2/R2) var controllerBinding = controller.getControllerBinding(KeyEvent.KEYCODE_BUTTON_L2) if (controllerBinding != null) { handleInputEvent( controllerBinding.binding, controller.state.triggerL > 0f, - controller.state.triggerL + controller.state.triggerL, + deviceId ) } @@ -139,12 +148,13 @@ class PhysicalControllerHandler( handleInputEvent( controllerBinding.binding, controller.state.triggerR > 0f, - controller.state.triggerR + controller.state.triggerR, + deviceId ) } // Process analog stick input - processJoystickInput(controller) + processJoystickInput(controller, deviceId) return true } } @@ -178,10 +188,12 @@ class PhysicalControllerHandler( * Process analog stick input and apply bindings. * Extracted from InputControlsView.processJoystickInput() */ - private fun processJoystickInput(controller: ExternalController) { + private fun processJoystickInput(controller: ExternalController, deviceId: Int = -1) { // Reset mouse movement offset at the start - contributions will be added during processing mouseMoveOffset.set(0f, 0f) + val deviceAxes = activeAxisBindings.getOrPut(deviceId) { mutableSetOf() } + val axes = intArrayOf( MotionEvent.AXIS_X, MotionEvent.AXIS_Y, @@ -207,27 +219,24 @@ class PhysicalControllerHandler( val activeKey = ExternalControllerBinding.getKeyCodeForAxis(axes[i], Mathf.sign(values[i])) val oppositeKey = if (activeKey == posKeyCode) negKeyCode else posKeyCode - // always send press (gamepad bindings need continuous offset updates) - activeAxisBindings.add(activeKey) + deviceAxes.add(activeKey) controller.getControllerBinding(activeKey)?.let { - handleInputEvent(it.binding, true, values[i]) + handleInputEvent(it.binding, true, values[i], deviceId) } - // release opposite direction (if it was active) - if (activeAxisBindings.remove(oppositeKey)) { + if (deviceAxes.remove(oppositeKey)) { controller.getControllerBinding(oppositeKey)?.let { - handleInputEvent(it.binding, false, 0f) + handleInputEvent(it.binding, false, 0f, deviceId) } } } else { - // release both directions only if they were active - if (activeAxisBindings.remove(posKeyCode)) { + if (deviceAxes.remove(posKeyCode)) { controller.getControllerBinding(posKeyCode)?.let { - handleInputEvent(it.binding, false, 0f) + handleInputEvent(it.binding, false, 0f, deviceId) } } - if (activeAxisBindings.remove(negKeyCode)) { + if (deviceAxes.remove(negKeyCode)) { controller.getControllerBinding(negKeyCode)?.let { - handleInputEvent(it.binding, false, 0f) + handleInputEvent(it.binding, false, 0f, deviceId) } } } @@ -238,78 +247,95 @@ class PhysicalControllerHandler( * Apply a binding to the virtual gamepad state and send to WinHandler. * Extracted from InputControlsView.handleInputEvent() */ - // offset: analog axis value for presses; must be 0f for releases (triggers use offset > 0f - // to determine pressed state, sticks gate on isActionDown, everything else ignores offset) - private fun handleInputEvent(binding: Binding, isActionDown: Boolean, offset: Float = 0f) { + private fun handleInputEvent(binding: Binding, isActionDown: Boolean, offset: Float = 0f, deviceId: Int = -1) { if (binding.isGamepad) { - val winHandler = xServer?.winHandler - val state = profile?.gamepadState + val winHandler = xServer?.winHandler ?: return + val controllerManager = ControllerManager.getInstance() - if (state != null) { - val buttonIdx = binding.ordinal - Binding.GAMEPAD_BUTTON_A.ordinal - if (buttonIdx <= ExternalController.IDX_BUTTON_R2.toInt()) { - when (buttonIdx) { - ExternalController.IDX_BUTTON_L2.toInt() -> { - state.triggerL = offset - state.setPressed(ExternalController.IDX_BUTTON_L2.toInt(), offset > 0f) - } - ExternalController.IDX_BUTTON_R2.toInt() -> { - state.triggerR = offset - state.setPressed(ExternalController.IDX_BUTTON_R2.toInt(), offset > 0f) - } - else -> state.setPressed(buttonIdx, isActionDown) + // Determine which player slot this device belongs to + val slot = if (deviceId >= 0) controllerManager.autoAssignDevice(deviceId) else 0 + if (slot < 0) return + + // 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 = ExternalController.getController(deviceId) + ?: profile?.getController(deviceId) + ?: return + winHandler.setControllerForSlot(slot, adopted) + slotController = adopted + } + + val state = slotController.state + + val buttonIdx = binding.ordinal - Binding.GAMEPAD_BUTTON_A.ordinal + if (buttonIdx <= ExternalController.IDX_BUTTON_R2.toInt()) { + when (buttonIdx) { + ExternalController.IDX_BUTTON_L2.toInt() -> { + state.triggerL = offset + state.setPressed(ExternalController.IDX_BUTTON_L2.toInt(), offset > 0f) + } + ExternalController.IDX_BUTTON_R2.toInt() -> { + state.triggerR = offset + state.setPressed(ExternalController.IDX_BUTTON_R2.toInt(), offset > 0f) } + else -> state.setPressed(buttonIdx, isActionDown) } - else { - when (binding) { - Binding.GAMEPAD_LEFT_THUMB_UP, Binding.GAMEPAD_LEFT_THUMB_DOWN -> { - state.thumbLY = if (isActionDown) offset else 0f - } - Binding.GAMEPAD_LEFT_THUMB_LEFT, Binding.GAMEPAD_LEFT_THUMB_RIGHT -> { - state.thumbLX = if (isActionDown) offset else 0f - } - Binding.GAMEPAD_RIGHT_THUMB_UP, Binding.GAMEPAD_RIGHT_THUMB_DOWN -> { - state.thumbRY = if (isActionDown) offset else 0f - } - Binding.GAMEPAD_RIGHT_THUMB_LEFT, Binding.GAMEPAD_RIGHT_THUMB_RIGHT -> { - state.thumbRX = if (isActionDown) offset else 0f - } - Binding.GAMEPAD_DPAD_UP -> { - state.dpad[0] = isActionDown - if(isActionDown) { - state.dpad[Binding.GAMEPAD_DPAD_DOWN.ordinal - Binding.GAMEPAD_DPAD_UP.ordinal ] = false - } + } + else { + when (binding) { + Binding.GAMEPAD_LEFT_THUMB_UP, Binding.GAMEPAD_LEFT_THUMB_DOWN -> { + state.thumbLY = if (isActionDown) offset else 0f + } + Binding.GAMEPAD_LEFT_THUMB_LEFT, Binding.GAMEPAD_LEFT_THUMB_RIGHT -> { + state.thumbLX = if (isActionDown) offset else 0f + } + Binding.GAMEPAD_RIGHT_THUMB_UP, Binding.GAMEPAD_RIGHT_THUMB_DOWN -> { + state.thumbRY = if (isActionDown) offset else 0f + } + Binding.GAMEPAD_RIGHT_THUMB_LEFT, Binding.GAMEPAD_RIGHT_THUMB_RIGHT -> { + state.thumbRX = if (isActionDown) offset else 0f + } + Binding.GAMEPAD_DPAD_UP -> { + state.dpad[0] = isActionDown + if(isActionDown) { + state.dpad[Binding.GAMEPAD_DPAD_DOWN.ordinal - Binding.GAMEPAD_DPAD_UP.ordinal ] = false } - Binding.GAMEPAD_DPAD_DOWN -> { - state.dpad[binding.ordinal - Binding.GAMEPAD_DPAD_UP.ordinal] = isActionDown - if(isActionDown) { - state.dpad[0] = false - } + } + Binding.GAMEPAD_DPAD_DOWN -> { + state.dpad[binding.ordinal - Binding.GAMEPAD_DPAD_UP.ordinal] = isActionDown + if(isActionDown) { + state.dpad[0] = false } - Binding.GAMEPAD_DPAD_LEFT -> { - state.dpad[binding.ordinal - Binding.GAMEPAD_DPAD_UP.ordinal] = isActionDown - if(isActionDown) { - state.dpad[Binding.GAMEPAD_DPAD_RIGHT.ordinal - Binding.GAMEPAD_DPAD_UP.ordinal ] = false - } + } + Binding.GAMEPAD_DPAD_LEFT -> { + state.dpad[binding.ordinal - Binding.GAMEPAD_DPAD_UP.ordinal] = isActionDown + if(isActionDown) { + state.dpad[Binding.GAMEPAD_DPAD_RIGHT.ordinal - Binding.GAMEPAD_DPAD_UP.ordinal ] = false } - Binding.GAMEPAD_DPAD_RIGHT -> { - state.dpad[binding.ordinal - Binding.GAMEPAD_DPAD_UP.ordinal] = isActionDown - if(isActionDown) { - state.dpad[Binding.GAMEPAD_DPAD_LEFT.ordinal - Binding.GAMEPAD_DPAD_UP.ordinal ] = false - } + } + Binding.GAMEPAD_DPAD_RIGHT -> { + state.dpad[binding.ordinal - Binding.GAMEPAD_DPAD_UP.ordinal] = isActionDown + if(isActionDown) { + state.dpad[Binding.GAMEPAD_DPAD_LEFT.ordinal - Binding.GAMEPAD_DPAD_UP.ordinal ] = false } - else -> {} } + else -> {} } + } - if (winHandler != null) { - val controller = winHandler.currentController - if (controller != null) { - controller.state.copy(state) - } - winHandler.sendGamepadState() - winHandler.sendVirtualGamepadState(state) - } + // Write state to the correct .mem buffer for this player slot + val buffer = winHandler.getBufferForSlot(slot) + if (buffer != null) winHandler.sendMemoryFileState(slotController, buffer) + + // UDP and virtual gamepad only for P1 (slot 0) for backward compat + if (slot == 0) { + profile?.gamepadState?.copy(state) + winHandler.sendGamepadState() + winHandler.sendVirtualGamepadState(state) } } else { // Handle special bindings 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 c48bea0562..07da99b816 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 @@ -1820,6 +1820,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 9b8b8ac2b2..f24991cc43 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -146,6 +146,8 @@ object ContainerUtils { sharpnessEffect = PrefManager.sharpnessEffect, sharpnessLevel = PrefManager.sharpnessLevel, sharpnessDenoise = PrefManager.sharpnessDenoise, + vibrationMode = PrefManager.vibrationMode, + vibrationIntensity = PrefManager.vibrationIntensity, ) } @@ -207,6 +209,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 { @@ -320,6 +324,11 @@ object ContainerUtils { sharpnessEffect = container.getExtra("sharpnessEffect", "None"), sharpnessLevel = container.getExtra("sharpnessLevel", "100").toIntOrNull() ?: 100, sharpnessDenoise = container.getExtra("sharpnessDenoise", "100").toIntOrNull() ?: 100, + vibrationMode = PrefManager.normalizeVibrationModeInput( + container.getExtra("vibrationMode", "controller"), + ), + vibrationIntensity = (container.getExtra("vibrationIntensity", "100").toIntOrNull() ?: 100) + .coerceIn(0, 100), // LSFG Vulkan frame generation lsfgEnabled = container.getExtra(LsfgVkManager.EXTRA_ARMED, "false").toBoolean(), ) @@ -488,6 +497,14 @@ object ContainerUtils { container.putExtra("sharpnessEffect", containerData.sharpnessEffect) container.putExtra("sharpnessLevel", containerData.sharpnessLevel.toString()) container.putExtra("sharpnessDenoise", containerData.sharpnessDenoise.toString()) + container.putExtra( + "vibrationMode", + PrefManager.normalizeVibrationModeInput(containerData.vibrationMode), + ) + container.putExtra( + "vibrationIntensity", + containerData.vibrationIntensity.coerceIn(0, 100).toString(), + ) // LSFG Vulkan frame generation container.putExtra(LsfgVkManager.EXTRA_ARMED, containerData.lsfgEnabled.toString()) try { @@ -859,6 +876,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 d05dcb5a74..a371e0df8c 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", @@ -99,6 +103,8 @@ data class ContainerData( val lsfgEnabled: Boolean = false, ) { companion object { + private val VALID_RESTORED_VIBRATION_MODES = setOf("off", "controller", "device") + val Saver = mapSaver( save = { state -> mapOf( @@ -117,6 +123,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, @@ -180,6 +188,11 @@ data class ContainerData( executablePath = savedMap["executablePath"] as String, installPath = savedMap["installPath"] as String, showFPS = savedMap["showFPS"] as Boolean, + 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/inputcontrols/ControllerManager.java b/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java index 1e65067ab0..0c1b5c26ee 100644 --- a/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java +++ b/app/src/main/java/com/winlator/inputcontrols/ControllerManager.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.SharedPreferences; import android.hardware.input.InputManager; +import android.os.Build; import android.preference.PreferenceManager; import android.util.SparseArray; import android.view.InputDevice; @@ -11,8 +12,12 @@ import app.gamenative.PrefManager; +import com.winlator.winhandler.WinHandler; + import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; public class ControllerManager { @@ -20,6 +25,7 @@ public class ControllerManager { private static ControllerManager instance; + /** Returns the singleton instance, creating it on first access. */ public static synchronized ControllerManager getInstance() { if (instance == null) { instance = new ControllerManager(); @@ -36,6 +42,11 @@ private ControllerManager() { private SharedPreferences preferences; private InputManager inputManager; + // Guards detectedDevices, slotAssignments, and enabledSlots against concurrent + // access from the UI thread (scanForDevices, autoAssignDevice, …) and the + // rumble poller thread (getAssignedDeviceForSlot, getDetectedDevices). + private final Object deviceStateLock = new Object(); + // This list will hold all physical game controllers detected by Android. private final List detectedDevices = new ArrayList<>(); @@ -44,7 +55,7 @@ private ControllerManager() { private final SparseArray slotAssignments = new SparseArray<>(); // This tracks which of the 4 player slots are enabled by the user. - private final boolean[] enabledSlots = new boolean[4]; + private final boolean[] enabledSlots = new boolean[WinHandler.MAX_PLAYERS]; public static final String PREF_PLAYER_SLOT_PREFIX = "controller_slot_"; public static final String PREF_ENABLED_SLOTS_PREFIX = "enabled_slot_"; @@ -69,33 +80,77 @@ public void init(Context context) { /** * Scans for all physically connected game controllers and updates the internal list. + * After scanning, evicts stale (disconnected) slot assignments and compacts the + * remaining connected devices to the lowest-numbered slots so that e.g. a lone DS4 + * always occupies slot 0 regardless of historical assignment order. */ public void scanForDevices() { - detectedDevices.clear(); - int[] deviceIds = inputManager.getInputDeviceIds(); - for (int deviceId : deviceIds) { - InputDevice device = inputManager.getInputDevice(deviceId); - // We only want physical gamepads/joysticks, not virtual ones or touchscreens. - if (device != null && !device.isVirtual() && isGameController(device)) { - detectedDevices.add(device); + synchronized (deviceStateLock) { + detectedDevices.clear(); + int[] deviceIds = inputManager.getInputDeviceIds(); + for (int deviceId : deviceIds) { + InputDevice device = inputManager.getInputDevice(deviceId); + if (device != null && !device.isVirtual() && isGameController(device)) { + detectedDevices.add(device); + } } + evictDisconnectedAndCompact(); } } + /** + * Removes slot assignments for devices that are no longer connected, + * then shifts the remaining assignments down to fill from slot 0. + * This prevents a single connected controller from being stuck at a + * high slot number while evshim only reads slot 0. + */ + private void evictDisconnectedAndCompact() { + Set connectedIds = new HashSet<>(); + for (InputDevice dev : detectedDevices) { + String id = getDeviceIdentifier(dev); + if (id != null) connectedIds.add(id); + } + + List keptIdentifiers = new ArrayList<>(); + List keptEnabled = new ArrayList<>(); + for (int i = 0; i < WinHandler.MAX_PLAYERS; i++) { + String identifier = slotAssignments.get(i); + if (identifier != null) { + if (connectedIds.contains(identifier)) { + keptIdentifiers.add(identifier); + keptEnabled.add(enabledSlots[i]); + } else { + android.util.Log.i("ControllerSlot", + "evicting stale slot=" + i + " identifier=" + identifier); + } + } + } + + slotAssignments.clear(); + for (int i = 0; i < WinHandler.MAX_PLAYERS; i++) { + if (i < keptIdentifiers.size()) { + slotAssignments.put(i, keptIdentifiers.get(i)); + enabledSlots[i] = keptEnabled.get(i); + } else { + enabledSlots[i] = false; + } + } + + saveAssignments(); + } + /** * Loads the saved player slot assignments and enabled states from SharedPreferences. */ private void loadAssignments() { slotAssignments.clear(); - for (int i = 0; i < 4; i++) { - // Load which device is assigned to this slot + for (int i = 0; i < WinHandler.MAX_PLAYERS; i++) { String prefKey = PREF_PLAYER_SLOT_PREFIX + i; String deviceIdentifier = preferences.getString(prefKey, null); if (deviceIdentifier != null) { slotAssignments.put(i, deviceIdentifier); } - // Load whether this slot is enabled. Default P1=true, P2-4=false. String enabledKey = PREF_ENABLED_SLOTS_PREFIX + i; enabledSlots[i] = preferences.getBoolean(enabledKey, i == 0); } @@ -103,22 +158,30 @@ private void loadAssignments() { /** * Saves the current player slot assignments and enabled states to SharedPreferences. + * Takes a consistent snapshot under deviceStateLock before writing, so concurrent + * mutations on the rumble-poller or UI thread cannot produce a torn save. + * SharedPreferences.apply() is called outside the lock to avoid holding it during I/O. */ public void saveAssignments() { + // Snapshot mutable state under lock for a consistent view. + String[] identifiers = new String[WinHandler.MAX_PLAYERS]; + boolean[] enabled = new boolean[WinHandler.MAX_PLAYERS]; + synchronized (deviceStateLock) { + for (int i = 0; i < WinHandler.MAX_PLAYERS; i++) { + identifiers[i] = slotAssignments.get(i); + enabled[i] = enabledSlots[i]; + } + } + SharedPreferences.Editor editor = preferences.edit(); - for (int i = 0; i < 4; i++) { - // Save the assigned device identifier - String deviceIdentifier = slotAssignments.get(i); + for (int i = 0; i < WinHandler.MAX_PLAYERS; i++) { String prefKey = PREF_PLAYER_SLOT_PREFIX + i; - if (deviceIdentifier != null) { - editor.putString(prefKey, deviceIdentifier); + if (identifiers[i] != null) { + editor.putString(prefKey, identifiers[i]); } else { editor.remove(prefKey); } - - // Save the enabled state - String enabledKey = PREF_ENABLED_SLOTS_PREFIX + i; - editor.putBoolean(enabledKey, enabledSlots[i]); + editor.putBoolean(PREF_ENABLED_SLOTS_PREFIX + i, enabled[i]); } editor.apply(); } @@ -167,32 +230,37 @@ public static boolean isGameController(InputDevice device) { */ public static String getDeviceIdentifier(InputDevice device) { if (device == null) return null; - // The descriptor is the most reliable unique ID for a device. - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { - return device.getDescriptor(); + // Pre-Q descriptors were unstable across reconnects for some vendors, so + // persisted assignments from old installs use "vendor_X_product_Y". Keep + // that format on < Q so upgrade paths don't lose slot bindings. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return "vendor_" + device.getVendorId() + "_product_" + device.getProductId(); } - // Fallback for older Android versions - return "vendor_" + device.getVendorId() + "_product_" + device.getProductId(); + return device.getDescriptor(); } /** * Returns the list of all detected physical game controllers. */ public List getDetectedDevices() { - return detectedDevices; + synchronized (deviceStateLock) { + return new ArrayList<>(detectedDevices); + } } /** * Returns the number of player slots the user has enabled. */ public int getEnabledPlayerCount() { - int count = 0; - for (boolean enabled : enabledSlots) { - if (enabled) { - count++; + synchronized (deviceStateLock) { + int count = 0; + for (boolean enabled : enabledSlots) { + if (enabled) { + count++; + } } + return count; } - return count; } /** @@ -202,21 +270,20 @@ public int getEnabledPlayerCount() { * @param device The physical InputDevice to assign. */ public void assignDeviceToSlot(int slotIndex, InputDevice device) { - if (slotIndex < 0 || slotIndex >= 4) return; + if (slotIndex < 0 || slotIndex >= WinHandler.MAX_PLAYERS) return; String newDeviceIdentifier = getDeviceIdentifier(device); if (newDeviceIdentifier == null) return; - // First, remove the new device from any slot it might already be in. - for (int i = 0; i < 4; i++) { - if (newDeviceIdentifier.equals(slotAssignments.get(i))) { - slotAssignments.remove(i); + synchronized (deviceStateLock) { + for (int i = 0; i < WinHandler.MAX_PLAYERS; i++) { + if (newDeviceIdentifier.equals(slotAssignments.get(i))) { + slotAssignments.remove(i); + } } + slotAssignments.put(slotIndex, newDeviceIdentifier); } - - // Assign the new device to the target slot. - slotAssignments.put(slotIndex, newDeviceIdentifier); - saveAssignments(); // Persist the change immediately. + saveAssignments(); } /** @@ -224,8 +291,10 @@ public void assignDeviceToSlot(int slotIndex, InputDevice device) { * @param slotIndex The player slot to un-assign (0-3). */ public void unassignSlot(int slotIndex) { - if (slotIndex < 0 || slotIndex >= 4) return; - slotAssignments.remove(slotIndex); + if (slotIndex < 0 || slotIndex >= WinHandler.MAX_PLAYERS) return; + synchronized (deviceStateLock) { + slotAssignments.remove(slotIndex); + } saveAssignments(); } @@ -239,16 +308,17 @@ public int getSlotForDevice(int deviceId) { String deviceIdentifier = getDeviceIdentifier(device); if (deviceIdentifier == null) return -1; - // Correctly loop through the sparse array to find the key for our value. - for (int i = 0; i < slotAssignments.size(); i++) { - int key = slotAssignments.keyAt(i); - String value = slotAssignments.valueAt(i); - if (deviceIdentifier.equals(value)) { - return key; // Return the key (the slot index), not the internal index! + synchronized (deviceStateLock) { + for (int i = 0; i < slotAssignments.size(); i++) { + int key = slotAssignments.keyAt(i); + String value = slotAssignments.valueAt(i); + if (deviceIdentifier.equals(value)) { + return key; + } } } - return -1; // Not found + return -1; } @@ -258,17 +328,18 @@ public int getSlotForDevice(int deviceId) { * @return The assigned InputDevice, or null if no device is assigned or if the device is not currently connected. */ public InputDevice getAssignedDeviceForSlot(int slotIndex) { - String assignedIdentifier = slotAssignments.get(slotIndex); - if (assignedIdentifier == null) return null; - - // Search our current list of connected devices for one that matches the saved identifier. - for (InputDevice device : detectedDevices) { - if (assignedIdentifier.equals(getDeviceIdentifier(device))) { - return device; // Found it. + synchronized (deviceStateLock) { + String assignedIdentifier = slotAssignments.get(slotIndex); + if (assignedIdentifier == null) return null; + + for (InputDevice device : detectedDevices) { + if (assignedIdentifier.equals(getDeviceIdentifier(device))) { + return device; + } } - } - return null; // The assigned device is not currently connected. + return null; + } } /** @@ -277,13 +348,75 @@ public InputDevice getAssignedDeviceForSlot(int slotIndex) { * @param isEnabled The new enabled state. */ public void setSlotEnabled(int slotIndex, boolean isEnabled) { - if (slotIndex < 0 || slotIndex >= 4) return; - enabledSlots[slotIndex] = isEnabled; + if (slotIndex < 0 || slotIndex >= WinHandler.MAX_PLAYERS) return; + synchronized (deviceStateLock) { + enabledSlots[slotIndex] = isEnabled; + } saveAssignments(); } + /** Returns whether the given player slot is enabled. */ public boolean isSlotEnabled(int slotIndex) { - if (slotIndex < 0 || slotIndex >= 4) return false; - return enabledSlots[slotIndex]; + if (slotIndex < 0 || slotIndex >= WinHandler.MAX_PLAYERS) return false; + synchronized (deviceStateLock) { + return enabledSlots[slotIndex]; + } + } + + /** + * Auto-assigns a device to the first available slot. + * If the device is already assigned, returns its existing slot. + * @param deviceId The Android device ID from the input event. + * @return The slot index (0-3), or -1 if no slot available or device is not a controller. + */ + public int autoAssignDevice(int deviceId) { + int existingSlot = getSlotForDevice(deviceId); + if (existingSlot >= 0) { + return isSlotEnabled(existingSlot) ? existingSlot : -1; + } + + InputDevice device = inputManager.getInputDevice(deviceId); + if (device == null || !isGameController(device)) { + return -1; + } + + int assignedSlot = -1; + synchronized (deviceStateLock) { + // Keep detectedDevices in sync so getAssignedDeviceForSlot can + // resolve this device without waiting for the next scanForDevices(). + String identifier = getDeviceIdentifier(device); + if (identifier == null) return -1; + boolean alreadyDetected = false; + for (InputDevice d : detectedDevices) { + if (identifier.equals(getDeviceIdentifier(d))) { alreadyDetected = true; break; } + } + if (!alreadyDetected) detectedDevices.add(device); + + for (int i = 0; i < WinHandler.MAX_PLAYERS; i++) { + if (slotAssignments.get(i) == null) { + // Inline the mutations that assignDeviceToSlot/setSlotEnabled + // would perform so both updates are atomic under one lock + // acquisition and saveAssignments() is called only once. + for (int j = 0; j < WinHandler.MAX_PLAYERS; j++) { + if (identifier.equals(slotAssignments.get(j))) { + slotAssignments.remove(j); + } + } + slotAssignments.put(i, identifier); + enabledSlots[i] = true; + assignedSlot = i; + break; + } + } + } + if (assignedSlot >= 0) { + saveAssignments(); + android.util.Log.i("ControllerSlot", "autoAssign: '" + device.getName() + + "' -> slot=" + assignedSlot); + return assignedSlot; + } + android.util.Log.w("ControllerSlot", "autoAssign: no slot available for '" + + device.getName() + "'"); + return -1; } } diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index df97ade100..dae8e616f8 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; @@ -51,7 +63,7 @@ public class WinHandler { private static final String TAG = "WinHandler"; private final ControllerManager controllerManager; - public static final int MAX_PLAYERS = 1; + public static final int MAX_PLAYERS = 4; private final MappedByteBuffer[] extraGamepadBuffers = new MappedByteBuffer[MAX_PLAYERS - 1]; private final ExternalController[] extraControllers = new ExternalController[MAX_PLAYERS - 1]; private MappedByteBuffer gamepadBuffer; @@ -77,18 +89,107 @@ public class WinHandler { private InputControlsView inputControlsView; private Thread rumblePollerThread; - private short lastLowFreq = 0; // Use 'short' instead of uint16_t - private short lastHighFreq = 0; // Use 'short' instead of uint16_t - private boolean isRumbling = false; + 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; // Add method to set InputControlsView 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) { + String newMode; + if (mode == null) { + newMode = DEFAULT_VIBRATION_MODE; + } else { + String normalized = mode.trim().toLowerCase(Locale.US); + 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) { + 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 { AUTO, DINPUT, @@ -116,8 +217,20 @@ 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..."); currentController = null; @@ -144,6 +257,45 @@ public void refreshControllerMappings() { } } + /** 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) { + 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); + } + } + // 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) { try { int size = this.sendData.position(); @@ -341,8 +493,28 @@ 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(); + } + // 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); + } DatagramSocket datagramSocket = this.socket; if (datagramSocket != null) { datagramSocket.close(); @@ -387,7 +559,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) { @@ -425,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); @@ -480,10 +673,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); @@ -493,7 +691,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); @@ -548,6 +746,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); @@ -567,107 +770,393 @@ 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() { + 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", + }; + + 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(); + } + rumblePollerThread = new Thread(() -> { + long now = SystemClock.elapsedRealtime(); + for (int p = 0; p < MAX_PLAYERS; p++) { + lastKeepaliveMs[p] = now; + lastDeviceRefreshMs[p] = now; + } + while (running) { - // --- MODIFIED: Get the current profile state on EVERY loop iteration --- - try { - // Always poll for rumble if gamepad buffer exists, regardless of controller state - // This ensures vibration works with built-in controllers (like Ayn Odin 2) - // even when virtual gamepad mode is disabled - if (gamepadBuffer != null) { - // Read the rumble values from the shared memory file. - short lowFreq = gamepadBuffer.getShort(32); - short highFreq = gamepadBuffer.getShort(34); - // Check if the rumble state has changed - if (lowFreq != lastLowFreq || highFreq != lastHighFreq) { - lastLowFreq = lowFreq; - lastHighFreq = highFreq; + now = SystemClock.elapsedRealtime(); + + 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) { + // 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; + lastDeviceRefreshMs[p] = now; if (lowFreq == 0 && highFreq == 0) { - stopVibration(); + stopVibrationForPlayer(p); } else { - startVibration(lowFreq, highFreq); + 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); } } + } catch (Exception e) { + // Buffer may be unmapped; continue polling } - } catch (Exception e) { - continue; } + + // 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; + } + } + 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) { + // 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); + } } 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 startVibration(short lowFreq, short highFreq) { - // --- Step 1: Calculate the base amplitude once at the top --- - int unsignedLowFreq = lowFreq & 0xFFFF; - int unsignedHighFreq = highFreq & 0xFFFF; - int dominantRumble = Math.max(unsignedLowFreq, unsignedHighFreq); - // This is the raw amplitude for a physical X-Input device - int amplitude = Math.round((float) dominantRumble / 65535.0f * 254.0f) + 1; - if (amplitude > 255) amplitude = 255; - // If amplitude is negligible, just stop and exit. - if (amplitude <= 1) { - stopVibration(); - return; + /** 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); } - isRumbling = true; // We know we are going to try to rumble. - // --- Step 2: Attempt to vibrate the physical controller first --- - if (currentController != null) { - InputDevice device = InputDevice.getDevice(currentController.getDeviceId()); - if (device != null) { - Vibrator controllerVibrator = device.getVibrator(); - if (controllerVibrator != null && controllerVibrator.hasVibrator()) { - // Vibrate the physical controller and then we are done. - controllerVibrator.vibrate(VibrationEffect.createOneShot(50, amplitude)); - return; + } + + /** + * 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; } + + // 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) { + 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); } - // --- Step 3: Fallback to phone vibration if physical controller fails or doesn't exist --- - Log.w("WinHandler", "No physical controller vibrator found, falling back to device vibration."); - Vibrator phoneVibrator = (Vibrator) activity.getSystemService(Context.VIBRATOR_SERVICE); - if (phoneVibrator != null && phoneVibrator.hasVibrator()) { - // --- HAPTIC CURVE LOGIC to make phone vibration feel better --- - float normalizedAmplitude = (float) amplitude / 255.0f; - float curvedAmplitude = (float) Math.pow(normalizedAmplitude, 0.6f); - int finalPhoneAmplitude = (int) (curvedAmplitude * 255); - if (finalPhoneAmplitude > 255) finalPhoneAmplitude = 255; - if (finalPhoneAmplitude <= 1) finalPhoneAmplitude = 0; - if (finalPhoneAmplitude > 0) { - phoneVibrator.vibrate(VibrationEffect.createOneShot(50, finalPhoneAmplitude)); + 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); } } - private void stopVibration() { - if (!isRumbling) return; // Simplified check - // Attempt to stop the physical controller's vibration if it exists - if (currentController != null) { - InputDevice device = InputDevice.getDevice(currentController.getDeviceId()); + + /** 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; + } + vibrateController(player, lowFreq, highFreq); + } + + /** 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) { - Vibrator vibrator = device.getVibrator(); - if (vibrator != null && vibrator.hasVibrator()) { - vibrator.cancel(); + 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); } - // Always attempt to stop the phone's vibration - Vibrator phoneVibrator = (Vibrator) activity.getSystemService(Context.VIBRATOR_SERVICE); - if (phoneVibrator != null) { - phoneVibrator.cancel(); + + 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); + } } - isRumbling = false; } + /** Broadcasts the current Player 1 gamepad state to all connected gamepad clients. */ public void sendGamepadState() { if (!this.initReceived || this.gamepadClients.isEmpty()) { return; @@ -695,80 +1184,73 @@ public void sendGamepadState() { } } - public boolean onGenericMotionEvent(MotionEvent event) { - boolean handled = false; - ExternalController externalController = this.currentController; - // Adopt newly connected controller if deviceId mismatches - if ((externalController == null || externalController.getDeviceId() != event.getDeviceId()) && ExternalController.isJoystickDevice(event)) { - ExternalController adopted = null; - // Try to get controller from profile first (has saved bindings) - if (inputControlsView != null) { + /** + * Resolves (or adopts) an ExternalController for the given device into the correct player slot. + * Returns the slot index (0-3) or -1 if the device is not a game controller. + */ + private int resolveControllerSlot(int deviceId) { + int slot = controllerManager.autoAssignDevice(deviceId); + if (slot < 0) return -1; + + ExternalController controller = getControllerForSlot(slot); + if (controller == null || controller.getDeviceId() != deviceId) { + // Real device first, profile wildcard as fallback. bllendev's PR #1261 + // inverted this and ended up adopting the wildcard ExternalController + // (id == "*") for every physical device, collapsing all slots to one + // controller. Keep the real-device-first order. + ExternalController adopted = ExternalController.getController(deviceId); + if (adopted == null && inputControlsView != null) { ControlsProfile profile = inputControlsView.getProfile(); if (profile != null) { - adopted = profile.getController(event.getDeviceId()); + adopted = profile.getController(deviceId); } } - // 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.onGenericMotionEvent: adopted controller %s(#%d)", adopted.getName(), adopted.getDeviceId()); + if (adopted != null) { + setControllerForSlot(slot, adopted); + Timber.d("WinHandler: adopted controller %s(#%d) to slot %d", adopted.getName(), adopted.getDeviceId(), slot); } } - if (externalController != null && externalController.getDeviceId() == event.getDeviceId() && (handled = this.currentController.updateStateFromMotionEvent(event))) { - if (handled) { - sendGamepadState(); - sendMemoryFileState(); - } + 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; + + int slot = resolveControllerSlot(event.getDeviceId()); + if (slot < 0) return false; + + ExternalController controller = getControllerForSlot(slot); + if (controller != null && controller.updateStateFromMotionEvent(event)) { + MappedByteBuffer buffer = getBufferForSlot(slot); + if (buffer != null) sendMemoryFileState(controller, buffer); + if (slot == 0) sendGamepadState(); + return true; } - return handled; + return false; } + /** 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; } @@ -780,16 +1262,14 @@ public void setPreferredInputApi(PreferredInputApi preferredInputApi) { this.preferredInputApi = preferredInputApi; } + /** Returns the ExternalController assigned to Player 1. */ public ExternalController getCurrentController() { return this.currentController; } - private void sendMemoryFileState() { - sendMemoryFileState(currentController, gamepadBuffer); - } - - private void sendMemoryFileState(ExternalController controller, MappedByteBuffer buffer) { + /** 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; } @@ -830,6 +1310,7 @@ private 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; @@ -870,6 +1351,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++) { @@ -890,6 +1372,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/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java index 99bfcbc1bb..de93d0eab8 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java @@ -37,6 +37,7 @@ import com.winlator.sysvshm.SysVSHMConnectionHandler; import com.winlator.sysvshm.SysVSHMRequestHandler; import com.winlator.sysvshm.SysVSharedMemory; +import com.winlator.winhandler.WinHandler; import com.winlator.xconnector.UnixSocketConfig; import com.winlator.xconnector.XConnectorEpoll; import com.winlator.xenvironment.ImageFs; @@ -179,11 +180,9 @@ public void setWorkingDir(File workingDir) { private int execGuestProgram() { - final int MAX_PLAYERS = 1; // old static method - - // Get the number of enabled players directly from ControllerManager. - final int enabledPlayerCount = MAX_PLAYERS; - for (int i = 0; i < enabledPlayerCount; i++) { + // Always pre-create all 4 mem files so controllers can be hot-plugged during gameplay. + // Unused gamepads just read zeroes (no-op in evshim). + 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. @@ -224,8 +223,21 @@ 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)); + // 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); } @@ -293,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); diff --git a/app/src/main/jniLibs/arm64-v8a/libevshim.so b/app/src/main/jniLibs/arm64-v8a/libevshim.so index 889871e61c..3c632512d4 100644 Binary files a/app/src/main/jniLibs/arm64-v8a/libevshim.so and b/app/src/main/jniLibs/arm64-v8a/libevshim.so differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8353c1c77e..b27baa2ff0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -737,6 +737,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/app/src/test/java/app/gamenative/input/MultiControllerTest.kt b/app/src/test/java/app/gamenative/input/MultiControllerTest.kt new file mode 100644 index 0000000000..1f9901114d --- /dev/null +++ b/app/src/test/java/app/gamenative/input/MultiControllerTest.kt @@ -0,0 +1,280 @@ +package app.gamenative.input + +import com.winlator.inputcontrols.ExternalController +import com.winlator.inputcontrols.GamepadState +import com.winlator.winhandler.WinHandler +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Unit tests for multi-controller support (Issue #565). + * + * Tests GamepadState independence and ExternalController isolation to verify + * that multiple controllers can maintain independent state simultaneously. + */ +class MultiControllerTest { + + // ======================================================================== + // GamepadState Independence + // ======================================================================== + + @Test + fun `GamepadState instances are independent - button press`() { + val state1 = GamepadState() + val state2 = GamepadState() + + state1.setPressed(ExternalController.IDX_BUTTON_A.toInt(), true) + + assertTrue(state1.isPressed(ExternalController.IDX_BUTTON_A.toInt())) + assertFalse(state2.isPressed(ExternalController.IDX_BUTTON_A.toInt())) + } + + @Test + fun `GamepadState instances are independent - analog sticks`() { + val state1 = GamepadState() + val state2 = GamepadState() + + state1.thumbLX = 0.75f + state1.thumbLY = -0.5f + + assertEquals(0.75f, state1.thumbLX, 0.001f) + assertEquals(-0.5f, state1.thumbLY, 0.001f) + assertEquals(0f, state2.thumbLX, 0.001f) + assertEquals(0f, state2.thumbLY, 0.001f) + } + + @Test + fun `GamepadState instances are independent - triggers`() { + val state1 = GamepadState() + val state2 = GamepadState() + + state1.triggerL = 0.8f + state2.triggerR = 0.6f + + assertEquals(0.8f, state1.triggerL, 0.001f) + assertEquals(0f, state1.triggerR, 0.001f) + assertEquals(0f, state2.triggerL, 0.001f) + assertEquals(0.6f, state2.triggerR, 0.001f) + } + + @Test + fun `GamepadState instances are independent - dpad`() { + val state1 = GamepadState() + val state2 = GamepadState() + + state1.dpad[0] = true // up + + assertTrue(state1.dpad[0]) + assertFalse(state2.dpad[0]) + } + + @Test + fun `GamepadState copy does not link instances`() { + val state1 = GamepadState() + val state2 = GamepadState() + + state1.setPressed(ExternalController.IDX_BUTTON_A.toInt(), true) + state1.thumbLX = 0.5f + state2.copy(state1) + + // Verify copy worked + assertTrue(state2.isPressed(ExternalController.IDX_BUTTON_A.toInt())) + assertEquals(0.5f, state2.thumbLX, 0.001f) + + // Modify state1 after copy - state2 should not change + state1.setPressed(ExternalController.IDX_BUTTON_B.toInt(), true) + state1.thumbLX = -1f + + assertFalse(state2.isPressed(ExternalController.IDX_BUTTON_B.toInt())) + assertEquals(0.5f, state2.thumbLX, 0.001f) + } + + @Test + fun `multiple GamepadState instances can hold different simultaneous inputs`() { + val p1State = GamepadState() + val p2State = GamepadState() + val p3State = GamepadState() + val p4State = GamepadState() + + p1State.setPressed(ExternalController.IDX_BUTTON_A.toInt(), true) + p1State.thumbLX = 1f + + p2State.setPressed(ExternalController.IDX_BUTTON_B.toInt(), true) + p2State.thumbLY = -1f + + p3State.setPressed(ExternalController.IDX_BUTTON_X.toInt(), true) + p3State.triggerL = 0.5f + + p4State.setPressed(ExternalController.IDX_BUTTON_Y.toInt(), true) + p4State.dpad[2] = true // down + + assertTrue(p1State.isPressed(ExternalController.IDX_BUTTON_A.toInt())) + assertFalse(p1State.isPressed(ExternalController.IDX_BUTTON_B.toInt())) + assertEquals(1f, p1State.thumbLX, 0.001f) + + assertTrue(p2State.isPressed(ExternalController.IDX_BUTTON_B.toInt())) + assertFalse(p2State.isPressed(ExternalController.IDX_BUTTON_A.toInt())) + assertEquals(-1f, p2State.thumbLY, 0.001f) + + assertTrue(p3State.isPressed(ExternalController.IDX_BUTTON_X.toInt())) + assertEquals(0.5f, p3State.triggerL, 0.001f) + + assertTrue(p4State.isPressed(ExternalController.IDX_BUTTON_Y.toInt())) + assertTrue(p4State.dpad[2]) + assertFalse(p1State.dpad[2]) + } + + // ======================================================================== + // Slot Constants + // ======================================================================== + + @Test + fun `MAX_PLAYERS is 4`() { + assertEquals(4, WinHandler.MAX_PLAYERS) + } + + // ======================================================================== + // GamepadState writeTo - buffer layout used by sendGamepadState UDP path + // ======================================================================== + + @Test + fun `writeTo encodes sticks as signed shorts`() { + val state = GamepadState() + state.thumbLX = 1f + state.thumbLY = -1f + + val buffer = ByteBuffer.allocate(32).order(ByteOrder.LITTLE_ENDIAN) + // writeTo format: buttons(2) + povHat(1) + sticks(8) + triggers(2) + state.writeTo(buffer) + buffer.flip() + + buffer.getShort() // skip buttons + buffer.get() // skip povHat + assertEquals(Short.MAX_VALUE, buffer.getShort()) // thumbLX = 1.0 + assertEquals((-Short.MAX_VALUE).toShort(), buffer.getShort()) // thumbLY = -1.0 + } + + @Test + fun `writeTo encodes triggers as unsigned bytes`() { + val state = GamepadState() + state.triggerL = 1f + state.triggerR = 0.5f + + val buffer = ByteBuffer.allocate(32).order(ByteOrder.LITTLE_ENDIAN) + state.writeTo(buffer) + buffer.flip() + + // Skip: buttons(2) + povHat(1) + sticks(8) = 11 bytes + buffer.position(11) + assertEquals(255.toByte(), buffer.get()) // triggerL fully pressed + assertEquals(127.toByte(), buffer.get()) // triggerR half pressed + } + + @Test + fun `writeTo encodes dpad as povHat`() { + val state = GamepadState() + state.dpad[0] = true // up + + val buffer = ByteBuffer.allocate(32).order(ByteOrder.LITTLE_ENDIAN) + state.writeTo(buffer) + buffer.flip() + + buffer.getShort() // skip buttons + val povHat = buffer.get() + assertEquals(0.toByte(), povHat) // up = 0 in POV hat convention + } + + @Test + fun `writeTo dpad diagonal encodes correctly`() { + val state = GamepadState() + state.dpad[0] = true // up + state.dpad[1] = true // right + + val buffer = ByteBuffer.allocate(32).order(ByteOrder.LITTLE_ENDIAN) + state.writeTo(buffer) + buffer.flip() + + buffer.getShort() // skip buttons + val povHat = buffer.get() + assertEquals(1.toByte(), povHat) // up+right = 1 + } + + // ======================================================================== + // GamepadState dpad helpers + // ======================================================================== + + @Test + fun `getDPadX returns correct direction`() { + val state = GamepadState() + + // dpad[1] = right, dpad[3] = left + state.dpad[1] = true + assertEquals(1.toByte(), state.dPadX) + + state.dpad[1] = false + state.dpad[3] = true + assertEquals((-1).toByte(), state.dPadX) + + state.dpad[3] = false + assertEquals(0.toByte(), state.dPadX) + } + + @Test + fun `getDPadY returns correct direction`() { + val state = GamepadState() + + // dpad[0] = up (-1), dpad[2] = down (1) + state.dpad[0] = true + assertEquals((-1).toByte(), state.dPadY) + + state.dpad[0] = false + state.dpad[2] = true + assertEquals(1.toByte(), state.dPadY) + } + + // ======================================================================== + // Button index constants match expected layout + // ======================================================================== + + @Test + fun `button indices are contiguous 0 through 11`() { + assertEquals(0, ExternalController.IDX_BUTTON_A.toInt()) + assertEquals(1, ExternalController.IDX_BUTTON_B.toInt()) + assertEquals(2, ExternalController.IDX_BUTTON_X.toInt()) + assertEquals(3, ExternalController.IDX_BUTTON_Y.toInt()) + assertEquals(4, ExternalController.IDX_BUTTON_L1.toInt()) + assertEquals(5, ExternalController.IDX_BUTTON_R1.toInt()) + assertEquals(6, ExternalController.IDX_BUTTON_SELECT.toInt()) + assertEquals(7, ExternalController.IDX_BUTTON_START.toInt()) + assertEquals(8, ExternalController.IDX_BUTTON_L3.toInt()) + assertEquals(9, ExternalController.IDX_BUTTON_R3.toInt()) + assertEquals(10, ExternalController.IDX_BUTTON_L2.toInt()) + assertEquals(11, ExternalController.IDX_BUTTON_R2.toInt()) + } + + @Test + fun `all 12 buttons can be set independently`() { + val state = GamepadState() + + // Press all buttons + for (i in 0..11) { + state.setPressed(i, true) + } + for (i in 0..11) { + assertTrue("Button $i should be pressed", state.isPressed(i)) + } + + // Release only button A + state.setPressed(ExternalController.IDX_BUTTON_A.toInt(), false) + assertFalse(state.isPressed(ExternalController.IDX_BUTTON_A.toInt())) + // All others still pressed + for (i in 1..11) { + assertTrue("Button $i should still be pressed", state.isPressed(i)) + } + } +} diff --git a/app/src/test/java/com/winlator/inputcontrols/ControllerManagerTest.kt b/app/src/test/java/com/winlator/inputcontrols/ControllerManagerTest.kt new file mode 100644 index 0000000000..989fc34aa4 --- /dev/null +++ b/app/src/test/java/com/winlator/inputcontrols/ControllerManagerTest.kt @@ -0,0 +1,59 @@ +package com.winlator.inputcontrols + +import android.view.InputDevice +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Verifies that [ControllerManager.getDeviceIdentifier] returns the correct + * identifier format per API level. Pre-Q (< 29) must use "vendor_X_product_Y"; + * Q+ must use the device descriptor. Changing this breaks persisted slot + * assignments for existing installs. + */ +@RunWith(RobolectricTestRunner::class) +class ControllerManagerTest { + + @Test + @Config(sdk = [28]) + fun `pre-Q device uses vendor_product format`() { + val device = mockk { + every { vendorId } returns 0x045E + every { productId } returns 0x028E + } + val id = ControllerManager.getDeviceIdentifier(device) + assertEquals("vendor_1118_product_654", id) + } + + @Test + @Config(sdk = [29]) + fun `Q device uses descriptor`() { + val descriptor = "usb-0000:00:14.0-2/input0" + val device = mockk { + every { getDescriptor() } returns descriptor + } + val id = ControllerManager.getDeviceIdentifier(device) + assertEquals(descriptor, id) + } + + @Test + @Config(sdk = [34]) + fun `post-Q device uses descriptor`() { + val descriptor = "bluetooth-AB:CD:EF:12:34:56-input0" + val device = mockk { + every { getDescriptor() } returns descriptor + } + val id = ControllerManager.getDeviceIdentifier(device) + assertEquals(descriptor, id) + } + + @Test + fun `null device returns null`() { + assertNull(ControllerManager.getDeviceIdentifier(null)) + } +} 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"