Skip to content

Commit 501eaa7

Browse files
committed
feat: PC-accurate vibration, inotify-driven rumble poller, evshim APK deploy
Brings together the full vibration pipeline from the original Fix-Vibration development branch, squashed into a single commit against the clean upstream base (no Fix-Steam-Client work included). Includes: * PC-accurate DualShock 4, DualSense, and XInput rumble mapping (WinHandler.java, evshim.c) * Multi-vibrator motor selection with ascending-ID sort and diagnostic logging (WinHandler.java) * FileObserver-driven rumble poller (replaces fixed-interval polling) for near-zero input/rumble latency (WinHandler.java) * 240 ms keepalive loop, vibration intensity + mode settings, per-game extras (WinHandler.java, PrefManager.kt, ControllerTab.kt, ContainerData.kt, ContainerUtils.kt, strings.xml) * evshim APK-copy + defensive LD_PRELOAD guard (BionicProgramLauncherComponent.java, CMakeLists.txt, evshim.c, SDL2 stub) * ControllerManager / PhysicalControllerHandler updates for reliable adoption + slot handling * Prebuilt arm64 libevshim.so + build-evshim.ps1 helper * Unit tests: MultiControllerTest, ControllerManagerTest
1 parent 61a5e24 commit 501eaa7

17 files changed

Lines changed: 1631 additions & 310 deletions

File tree

app/src/main/cpp/extras/CMakeLists.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,8 @@ target_link_libraries(extras
1717
GLESv2
1818
GLESv3
1919
adrenotools)
20+
21+
add_library(evshim SHARED evshim.c)
22+
target_include_directories(evshim PRIVATE sdl2_stub)
23+
target_link_libraries(evshim dl)
24+
set_target_properties(evshim PROPERTIES LINK_FLAGS "-Wl,--as-needed")

app/src/main/cpp/extras/evshim.c

Lines changed: 117 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
#include <unistd.h>
1515
#include <SDL2/SDL.h>
1616
#include <stdarg.h>
17+
#include <stdatomic.h>
18+
#include <sys/inotify.h>
19+
#include <poll.h>
20+
#include <time.h>
1721

1822
static int g_debug_enabled = 0;
1923

@@ -28,6 +32,11 @@ static int rumble_fd[MAX_GAMEPADS] = {-1};
2832
static void *handle = NULL;
2933
static pthread_mutex_t shm_mutex = PTHREAD_MUTEX_INITIALIZER;
3034

35+
/* Set to 1 on the player index being refreshed by the SDL keepalive so that
36+
* the resulting re-entrant OnRumble() call does not overwrite shared memory
37+
* (which may have been zeroed by a stop command that raced the keepalive). */
38+
static atomic_int g_keepalive_active[MAX_GAMEPADS];
39+
3140
struct gamepad_io {
3241
int16_t lx, ly, rx, ry, lt, rt;
3342
uint8_t btn[15];
@@ -47,6 +56,18 @@ static int (*p_SDL_JoystickSetVirtualHat)(SDL_Joystick *joystick, int hat, uint8
4756
static void (*p_SDL_PumpEvents)(void);
4857
static void (*p_SDL_Delay)(uint32_t ms);
4958
static void (*p_SDL_GetVersion)(SDL_version *);
59+
static int (*p_SDL_JoystickRumble)(SDL_Joystick *, uint16_t, uint16_t, uint32_t);
60+
61+
/* Per-player rumble state for SDL keepalive.
62+
* Wine/XInput "set and forget" semantics expect motors to stay on until
63+
* explicitly stopped, but SDL's internal timer auto-expires rumble after
64+
* the duration Wine passes (~1 s). We periodically re-send the last
65+
* non-zero values to reset that timer so the auto-expiry never fires. */
66+
static uint16_t last_rumble_low [MAX_GAMEPADS];
67+
static uint16_t last_rumble_high[MAX_GAMEPADS];
68+
69+
#define RUMBLE_KEEPALIVE_TICKS 100 /* 100 × 5 ms = 500 ms */
70+
#define RUMBLE_KEEPALIVE_DUR_MS 2000 /* SDL expiry reset window */
5071

5172

5273
#define GETFUNCPTR(name)\
@@ -63,11 +84,26 @@ static int OnRumble(void *userdata,
6384
int idx = (int)(intptr_t)userdata;
6485
if (idx < 0 || idx >= MAX_GAMEPADS || rumble_fd[idx] < 0) return -1;
6586

66-
uint16_t vals[2] = { low_frequency_rumble, high_frequency_rumble };
87+
/* When the SDL keepalive re-invokes SDL_JoystickRumble to reset SDL's
88+
* internal expiry timer, it synchronously calls back into OnRumble.
89+
* We must NOT update last_rumble or pwrite in that case: the game may
90+
* have already sent OnRumble(0,0) to stop vibration, and re-writing
91+
* the old non-zero values would restart rumble on the Android side. */
92+
if (atomic_load(&g_keepalive_active[idx])) {
93+
LOGD("Rumble P%d low=%u high=%u [keepalive noop]\n", idx,
94+
low_frequency_rumble, high_frequency_rumble);
95+
return 0;
96+
}
6797

68-
pthread_mutex_lock(&shm_mutex); /* NEW */
98+
pthread_mutex_lock(&shm_mutex);
99+
100+
last_rumble_low [idx] = low_frequency_rumble;
101+
last_rumble_high[idx] = high_frequency_rumble;
102+
103+
uint16_t vals[2] = { low_frequency_rumble, high_frequency_rumble };
69104
ssize_t w = pwrite(rumble_fd[idx], vals, sizeof(vals), 32);
70-
pthread_mutex_unlock(&shm_mutex); /* NEW */
105+
106+
pthread_mutex_unlock(&shm_mutex);
71107

72108
if (w != (ssize_t)sizeof(vals))
73109
LOGE("Rumble write failed (P%d): %s\n", idx, strerror(errno));
@@ -93,7 +129,7 @@ static void *vjoy_updater(void *arg)
93129

94130
int fd = read_fd[idx];
95131
if (fd < 0) {
96-
LOGE("P%d: read_fd not initialised aborting thread\n", idx);
132+
LOGE("P%d: read_fd not initialised - aborting thread\n", idx);
97133
return NULL;
98134
}
99135

@@ -105,12 +141,43 @@ static void *vjoy_updater(void *arg)
105141

106142
struct gamepad_io cur, last_state = {0};
107143

108-
LOGI("VJOY UPDATER P%d running (PID %d)\n", idx, getpid());
144+
/* Set up inotify to wake immediately when WinHandler writes new input
145+
* state (offsets 0-31) rather than sleeping a fixed 5 ms between reads.
146+
* The same watch also fires on rumble writes (offset 32-35); those
147+
* wakeups are benign - we re-read and find the input portion unchanged. */
148+
char watch_path[256];
149+
if (idx == 0) {
150+
snprintf(watch_path, sizeof watch_path,
151+
"/data/data/app.gamenative/files/imagefs/tmp/gamepad.mem");
152+
} else {
153+
snprintf(watch_path, sizeof watch_path,
154+
"/data/data/app.gamenative/files/imagefs/tmp/gamepad%d.mem", idx);
155+
}
156+
int ino_fd = inotify_init1(IN_NONBLOCK);
157+
if (ino_fd >= 0) {
158+
if (inotify_add_watch(ino_fd, watch_path, IN_MODIFY) < 0) {
159+
LOGE("P%d: inotify_add_watch failed: %s - falling back to 5 ms poll\n",
160+
idx, strerror(errno));
161+
close(ino_fd);
162+
ino_fd = -1;
163+
}
164+
} else {
165+
LOGE("P%d: inotify_init1 failed: %s - falling back to 5 ms poll\n",
166+
idx, strerror(errno));
167+
}
168+
169+
/* Wall-clock keepalive: replaces tick counter so the rumble refresh
170+
* cadence stays correct regardless of how fast inotify wakes the loop. */
171+
struct timespec last_keepalive;
172+
clock_gettime(CLOCK_MONOTONIC, &last_keepalive);
173+
174+
LOGI("VJOY UPDATER P%d running (PID %d, inotify=%s)\n",
175+
idx, getpid(), ino_fd >= 0 ? "on" : "off");
109176

110177
for (;;) {
111178
pthread_mutex_lock(&shm_mutex);
112-
113-
ssize_t n = read(fd, &cur, sizeof cur);
179+
ssize_t n = pread(fd, &cur, sizeof cur, 0);
180+
pthread_mutex_unlock(&shm_mutex);
114181

115182
if (n == sizeof cur && memcmp(&cur, &last_state, sizeof cur) != 0) {
116183

@@ -132,11 +199,51 @@ static void *vjoy_updater(void *arg)
132199
LOGE("P%d: read error: %s\n", idx, strerror(errno));
133200
}
134201

135-
pthread_mutex_unlock(&shm_mutex);
202+
/* Re-send last non-zero rumble to SDL periodically so its internal
203+
* expiry timer never fires the false OnRumble(0,0). This preserves
204+
* XInput "set and forget" semantics through the SDL translation.
205+
* Use wall clock so the 500 ms cadence is correct even when inotify
206+
* wakes the loop much faster than the old fixed 5 ms sleep did. */
207+
if (p_SDL_JoystickRumble) {
208+
struct timespec now;
209+
clock_gettime(CLOCK_MONOTONIC, &now);
210+
long elapsed_ms = (now.tv_sec - last_keepalive.tv_sec) * 1000L
211+
+ (now.tv_nsec - last_keepalive.tv_nsec) / 1000000L;
212+
if (elapsed_ms >= RUMBLE_KEEPALIVE_TICKS * 5) { /* 100 * 5 ms = 500 ms */
213+
last_keepalive = now;
214+
uint16_t kl, kh;
215+
pthread_mutex_lock(&shm_mutex);
216+
kl = last_rumble_low[idx];
217+
kh = last_rumble_high[idx];
218+
pthread_mutex_unlock(&shm_mutex);
219+
if (kl != 0 || kh != 0) {
220+
/* Flag this player's slot before calling SDL so that the
221+
* synchronous OnRumble() re-entry is recognised as a
222+
* keepalive and does not overwrite shared memory. */
223+
atomic_store(&g_keepalive_active[idx], 1);
224+
p_SDL_JoystickRumble(js, kl, kh, RUMBLE_KEEPALIVE_DUR_MS);
225+
atomic_store(&g_keepalive_active[idx], 0);
226+
LOGD("Rumble keepalive P%d low=%u high=%u\n", idx, kl, kh);
227+
}
228+
}
229+
}
136230

137-
p_SDL_Delay(5);
231+
/* Wait up to 5 ms for the next file modification then drain the
232+
* inotify queue so events do not accumulate across iterations.
233+
* Falls back to p_SDL_Delay(5) if inotify is unavailable. */
234+
if (ino_fd >= 0) {
235+
struct pollfd pfd = { ino_fd, POLLIN, 0 };
236+
poll(&pfd, 1, 5);
237+
/* Drain all queued events (non-blocking). */
238+
char ibuf[256];
239+
while (read(ino_fd, ibuf, sizeof ibuf) > 0) {}
240+
} else {
241+
p_SDL_Delay(5);
242+
}
138243
}
139244

245+
/* Unreachable in normal operation; cleanup for early-return paths above. */
246+
if (ino_fd >= 0) close(ino_fd);
140247
return NULL;
141248
}
142249

@@ -156,6 +263,7 @@ static void initialize_all_pads(void)
156263
GETFUNCPTR(SDL_JoystickSetVirtualAxis); GETFUNCPTR(SDL_JoystickSetVirtualButton);
157264
GETFUNCPTR(SDL_JoystickSetVirtualHat); GETFUNCPTR(SDL_PumpEvents);
158265
GETFUNCPTR(SDL_Delay); GETFUNCPTR(SDL_GetVersion);
266+
GETFUNCPTR(SDL_JoystickRumble);
159267

160268
p_SDL_Init(SDL_INIT_JOYSTICK);
161269

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Minimal SDL2 type stubs for cross-compiling evshim.c with the Android NDK.
3+
* Only the types, constants, and struct layouts that evshim.c actually uses
4+
* are defined here. All SDL functions are loaded at runtime via dlsym().
5+
*
6+
* Struct layout must match SDL2 >= 2.24.0 (SDL_VirtualJoystickDesc).
7+
*/
8+
#ifndef SDL_STUB_H
9+
#define SDL_STUB_H
10+
11+
#include <stdint.h>
12+
13+
typedef uint8_t Uint8;
14+
typedef uint16_t Uint16;
15+
typedef uint32_t Uint32;
16+
typedef int32_t Sint32;
17+
18+
#define SDLCALL
19+
20+
#define SDL_INIT_JOYSTICK 0x00000200u
21+
22+
typedef struct SDL_Joystick SDL_Joystick;
23+
24+
typedef struct SDL_version {
25+
Uint8 major;
26+
Uint8 minor;
27+
Uint8 patch;
28+
} SDL_version;
29+
30+
typedef enum {
31+
SDL_JOYSTICK_TYPE_UNKNOWN = 0,
32+
SDL_JOYSTICK_TYPE_GAMECONTROLLER,
33+
SDL_JOYSTICK_TYPE_WHEEL,
34+
SDL_JOYSTICK_TYPE_ARCADE_STICK,
35+
SDL_JOYSTICK_TYPE_FLIGHT_STICK,
36+
SDL_JOYSTICK_TYPE_DANCE_PAD,
37+
SDL_JOYSTICK_TYPE_GUITAR,
38+
SDL_JOYSTICK_TYPE_DRUM_KIT,
39+
SDL_JOYSTICK_TYPE_ARCADE_PAD,
40+
SDL_JOYSTICK_TYPE_THROTTLE
41+
} SDL_JoystickType;
42+
43+
#define SDL_VIRTUAL_JOYSTICK_DESC_VERSION 1
44+
45+
typedef struct SDL_VirtualJoystickDesc {
46+
Uint16 version;
47+
Uint16 type;
48+
Uint16 naxes;
49+
Uint16 nbuttons;
50+
Uint16 nhats;
51+
Uint16 vendor_id;
52+
Uint16 product_id;
53+
Uint16 padding;
54+
Uint32 button_mask;
55+
Uint32 axis_mask;
56+
const char *name;
57+
void *userdata;
58+
void (SDLCALL *Update)(void *userdata);
59+
void (SDLCALL *SetPlayerIndex)(void *userdata, int player_index);
60+
int (SDLCALL *Rumble)(void *userdata, Uint16 low_frequency_rumble, Uint16 high_frequency_rumble);
61+
int (SDLCALL *RumbleTriggers)(void *userdata, Uint16 left_rumble, Uint16 right_rumble);
62+
int (SDLCALL *SetLED)(void *userdata, Uint8 red, Uint8 green, Uint8 blue);
63+
int (SDLCALL *SendEffect)(void *userdata, const void *data, int size);
64+
} SDL_VirtualJoystickDesc;
65+
66+
#endif /* SDL_STUB_H */

app/src/main/java/app/gamenative/PrefManager.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,34 @@ object PrefManager {
216216
setPref(SHARPNESS_DENOISE, value.coerceIn(0, 100))
217217
}
218218

219+
private val VALID_VIBRATION_MODES = setOf("off", "controller", "device", "both")
220+
private const val DEFAULT_VIBRATION_MODE = "controller"
221+
222+
/** Normalizes a vibration mode string to a known value, falling back to the default. */
223+
private fun normalizeVibrationMode(value: String?): String {
224+
val v = value?.trim()?.lowercase().orEmpty()
225+
return if (v in VALID_VIBRATION_MODES) v else DEFAULT_VIBRATION_MODE
226+
}
227+
228+
/**
229+
* Returns a value in `VALID_VIBRATION_MODES`, for prefs, container extras, or WinHandler.
230+
*/
231+
fun normalizeVibrationModeInput(value: String?): String = normalizeVibrationMode(value)
232+
233+
private val VIBRATION_MODE = stringPreferencesKey("vibration_mode")
234+
var vibrationMode: String
235+
get() = normalizeVibrationMode(getPref(VIBRATION_MODE, DEFAULT_VIBRATION_MODE))
236+
set(value) {
237+
setPref(VIBRATION_MODE, normalizeVibrationMode(value))
238+
}
239+
240+
private val VIBRATION_INTENSITY = intPreferencesKey("vibration_intensity")
241+
var vibrationIntensity: Int
242+
get() = getPref(VIBRATION_INTENSITY, 100).coerceIn(0, 100)
243+
set(value) {
244+
setPref(VIBRATION_INTENSITY, value.coerceIn(0, 100))
245+
}
246+
219247
private val CONTAINER_VARIANT = stringPreferencesKey("container_variant")
220248
var containerVariant: String
221249
get() = getPref(CONTAINER_VARIANT, Container.DEFAULT_VARIANT)

app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
package app.gamenative.ui.component.dialog
22

3+
import androidx.compose.foundation.layout.Column
34
import androidx.compose.foundation.layout.Row
5+
import androidx.compose.foundation.layout.padding
46
import androidx.compose.material.icons.Icons
57
import androidx.compose.material.icons.filled.Settings
68
import androidx.compose.material3.Icon
79
import androidx.compose.material3.IconButton
10+
import androidx.compose.material3.Slider
811
import androidx.compose.material3.Switch
912
import androidx.compose.material3.Text
1013
import androidx.compose.runtime.Composable
1114
import androidx.compose.runtime.getValue
15+
import androidx.compose.runtime.mutableIntStateOf
1216
import androidx.compose.runtime.mutableStateOf
1317
import androidx.compose.runtime.remember
1418
import androidx.compose.runtime.setValue
1519
import androidx.compose.ui.Alignment
20+
import androidx.compose.ui.Modifier
1621
import androidx.compose.ui.res.stringResource
22+
import androidx.compose.ui.unit.dp
23+
import app.gamenative.PrefManager
1724
import app.gamenative.R
1825
import app.gamenative.data.TouchGestureConfig
1926
import app.gamenative.ui.component.settings.SettingsListDropdown
@@ -23,10 +30,12 @@ import com.alorma.compose.settings.ui.SettingsGroup
2330
import com.alorma.compose.settings.ui.SettingsSwitch
2431
import com.alorma.compose.settings.ui.SettingsMenuLink
2532
import com.winlator.container.Container
33+
import kotlin.math.roundToInt
2634

2735
@Composable
2836
fun ControllerTabContent(state: ContainerConfigState, default: Boolean) {
2937
val config = state.config.value
38+
val normalizedVibrationMode = PrefManager.normalizeVibrationModeInput(config.vibrationMode)
3039
var showGestureDialog by remember { mutableStateOf(false) }
3140

3241
SettingsGroup() {
@@ -65,6 +74,41 @@ fun ControllerTabContent(state: ContainerConfigState, default: Boolean) {
6574
state.config.value = config.copy(dinputMapperType = if (index == 0) 1 else 2)
6675
},
6776
)
77+
val vibrationModes = listOf(
78+
stringResource(R.string.vibration_mode_option_off),
79+
stringResource(R.string.vibration_mode_option_controller),
80+
stringResource(R.string.vibration_mode_option_device),
81+
stringResource(R.string.vibration_mode_option_both),
82+
)
83+
val vibrationModeValues = listOf("off", "controller", "device", "both")
84+
val vibrationModeIndex = vibrationModeValues.indexOf(normalizedVibrationMode).coerceAtLeast(0)
85+
SettingsListDropdown(
86+
colors = settingsTileColors(),
87+
title = { Text(text = stringResource(R.string.vibration_mode)) },
88+
value = vibrationModeIndex,
89+
items = vibrationModes,
90+
onItemSelected = { index ->
91+
state.config.value = config.copy(vibrationMode = vibrationModeValues[index])
92+
},
93+
)
94+
if (normalizedVibrationMode != "off") {
95+
var intensitySlider by remember(config.vibrationIntensity) {
96+
mutableIntStateOf(config.vibrationIntensity.coerceIn(0, 100))
97+
}
98+
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
99+
Text(text = stringResource(R.string.vibration_intensity))
100+
Slider(
101+
value = intensitySlider.toFloat(),
102+
onValueChange = { newValue ->
103+
val clamped = newValue.roundToInt().coerceIn(0, 100)
104+
intensitySlider = clamped
105+
state.config.value = config.copy(vibrationIntensity = clamped)
106+
},
107+
valueRange = 0f..100f,
108+
)
109+
Text(text = "$intensitySlider%")
110+
}
111+
}
68112
SettingsSwitch(
69113
colors = settingsTileColorsAlt(),
70114
title = { Text(text = stringResource(R.string.disable_mouse_input)) },

0 commit comments

Comments
 (0)