Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 118 additions & 12 deletions app/src/main/cpp/extras/evshim.c
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@
#include <dlfcn.h>
#include <fcntl.h>
#include <errno.h>
#include <stddef.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <SDL2/SDL.h>
#include <stdarg.h>
#include <sys/inotify.h>
#include <poll.h>
#include <time.h>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

static int g_debug_enabled = 0;

Expand All @@ -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];
Expand All @@ -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)\
Expand All @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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));
Expand All @@ -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;
}

Expand All @@ -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);
Expand All @@ -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))
Expand All @@ -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);

Expand Down
66 changes: 66 additions & 0 deletions app/src/main/cpp/extras/sdl2_stub/SDL2/SDL.h
Original file line number Diff line number Diff line change
@@ -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 <stdint.h>

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 */
28 changes: 28 additions & 0 deletions app/src/main/java/app/gamenative/PrefManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
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
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) {
Expand Down Expand Up @@ -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)) },
Expand Down
Loading