Skip to content

Commit 4acb646

Browse files
committed
feat: PC-accurate per-controller vibration on top of upstream multi-controller
Layered the vibration improvements from the pre-cleanup branch (commit 878469f^) onto the contributor's multi-controller PR (utkarshdalal#1261, merged upstream as dacbfa0). Upstream's slot infrastructure (getBufferForSlot/setControllerForSlot/resolveControllerSlot) is preserved unchanged; the additions are purely vibration quality. What this changes: - WinHandler.java: per-slot rumble poller wakes via FileObserver (inotify) instead of a 20 ms sleep loop, dispatches dual-motor rumble via VibratorManager (API 31+) with ascending-ID motor sort, applies a 240 ms keepalive cycle so long rumbles don't get cut off, refreshes the InputDevice handle periodically to handle hot-swap mid-rumble, and scales amplitude by the user's intensity preference. - evshim.c: per-player atomic g_keepalive_active flag + per-player last_rumble cache + inotify-based wake replace the prior 5 ms poll fallback, eliminating dropped/stuck rumbles on rapid game-side pulses. Includes the narrow-rumble re-entry guard (4a08598). - libevshim.so: rebuilt against new evshim.c (26288 bytes). Settings UI (ControllerTab): - Vibration Mode dropdown: off / controller / device. (Pre-cleanup had a 'both' option; dropped per design — pick one target, not both.) - Vibration Intensity slider 0-100%, hidden when mode is 'off'. Persistence: - ContainerData: vibrationMode + vibrationIntensity fields with saver / restorer wiring. - ContainerUtils: roundtrips both fields between PrefManager defaults, ContainerData, and Container.extras. - PrefManager: VIBRATION_MODE + VIBRATION_INTENSITY prefs with normalizeVibrationModeInput() helper for safe deserialization. - XServerScreen: pushes per-container vibration mode + intensity into WinHandler at game launch. What is intentionally left as upstream: - All multi-controller routing, slot assignment, hot-plug, and the ControllerManager / PhysicalControllerHandler integration. The contributor's design for those is what the PR keeps. build-evshim.ps1 and the SDL2 stub header are added to support recompiling libevshim.so without an SDL2 SDK install.
1 parent 98bd367 commit 4acb646

6 files changed

Lines changed: 265 additions & 99 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ object PrefManager {
216216
setPref(SHARPNESS_DENOISE, value.coerceIn(0, 100))
217217
}
218218

219-
private val VALID_VIBRATION_MODES = setOf("off", "controller", "device", "both")
219+
private val VALID_VIBRATION_MODES = setOf("off", "controller", "device")
220220
private const val DEFAULT_VIBRATION_MODE = "controller"
221221

222222
/** Normalizes a vibration mode string to a known value, falling back to the default. */

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

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
11
package app.gamenative.ui.component.dialog
22

33
import androidx.compose.foundation.layout.Column
4-
import androidx.compose.foundation.layout.Row
54
import androidx.compose.foundation.layout.padding
6-
import androidx.compose.material.icons.Icons
7-
import androidx.compose.material.icons.filled.Settings
8-
import androidx.compose.material3.Icon
9-
import androidx.compose.material3.IconButton
105
import androidx.compose.material3.Slider
11-
import androidx.compose.material3.Switch
126
import androidx.compose.material3.Text
137
import androidx.compose.runtime.Composable
148
import androidx.compose.runtime.getValue
159
import androidx.compose.runtime.mutableIntStateOf
16-
import androidx.compose.runtime.mutableStateOf
1710
import androidx.compose.runtime.remember
1811
import androidx.compose.runtime.setValue
19-
import androidx.compose.ui.Alignment
2012
import androidx.compose.ui.Modifier
2113
import androidx.compose.ui.res.stringResource
2214
import androidx.compose.ui.unit.dp
@@ -34,7 +26,6 @@ import kotlin.math.roundToInt
3426
fun ControllerTabContent(state: ContainerConfigState, default: Boolean) {
3527
val config = state.config.value
3628
val normalizedVibrationMode = PrefManager.normalizeVibrationModeInput(config.vibrationMode)
37-
var showGestureDialog by remember { mutableStateOf(false) }
3829

3930
SettingsGroup() {
4031
if (!default) {
@@ -76,9 +67,8 @@ fun ControllerTabContent(state: ContainerConfigState, default: Boolean) {
7667
stringResource(R.string.vibration_mode_option_off),
7768
stringResource(R.string.vibration_mode_option_controller),
7869
stringResource(R.string.vibration_mode_option_device),
79-
stringResource(R.string.vibration_mode_option_both),
8070
)
81-
val vibrationModeValues = listOf("off", "controller", "device", "both")
71+
val vibrationModeValues = listOf("off", "controller", "device")
8272
val vibrationModeIndex = vibrationModeValues.indexOf(normalizedVibrationMode).coerceAtLeast(0)
8373
SettingsListDropdown(
8474
colors = settingsTileColors(),

app/src/main/java/com/winlator/container/ContainerData.kt

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ data class ContainerData(
2424
val executablePath: String = "",
2525
val installPath: String = "",
2626
val showFPS: Boolean = false,
27+
/** Vibration target: "off", "controller", "device" **/
28+
val vibrationMode: String = "controller",
29+
/** Vibration intensity percentage (0-100) **/
30+
val vibrationIntensity: Int = 100,
2731
val launchRealSteam: Boolean = false,
2832
val disableSteamOverlay: Boolean = true,
2933
/** Install-relative subdir for SDK-cloud games that keep saves in their install dir
@@ -98,10 +102,6 @@ data class ContainerData(
98102
val sharpnessEffect: String = "None",
99103
val sharpnessLevel: Int = 100,
100104
val sharpnessDenoise: Int = 100,
101-
/** Vibration target: "off", "controller", "device", "both" **/
102-
val vibrationMode: String = "controller",
103-
/** Vibration intensity percentage (0-100) **/
104-
val vibrationIntensity: Int = 100,
105105
) {
106106
companion object {
107107
val Saver = mapSaver(
@@ -122,6 +122,8 @@ data class ContainerData(
122122
"executablePath" to state.executablePath,
123123
"installPath" to state.installPath,
124124
"showFPS" to state.showFPS,
125+
"vibrationMode" to state.vibrationMode,
126+
"vibrationIntensity" to state.vibrationIntensity,
125127
"launchRealSteam" to state.launchRealSteam,
126128
"disableSteamOverlay" to state.disableSteamOverlay,
127129
"sdkCloudSaveSubdir" to state.sdkCloudSaveSubdir,
@@ -167,8 +169,6 @@ data class ContainerData(
167169
"sharpnessEffect" to state.sharpnessEffect,
168170
"sharpnessLevel" to state.sharpnessLevel,
169171
"sharpnessDenoise" to state.sharpnessDenoise,
170-
"vibrationMode" to state.vibrationMode,
171-
"vibrationIntensity" to state.vibrationIntensity,
172172
)
173173
},
174174
restore = { savedMap ->
@@ -188,6 +188,8 @@ data class ContainerData(
188188
executablePath = savedMap["executablePath"] as String,
189189
installPath = savedMap["installPath"] as String,
190190
showFPS = savedMap["showFPS"] as Boolean,
191+
vibrationMode = (savedMap["vibrationMode"] as? String) ?: "controller",
192+
vibrationIntensity = (savedMap["vibrationIntensity"] as? Int) ?: 100,
191193
launchRealSteam = savedMap["launchRealSteam"] as Boolean,
192194
disableSteamOverlay = (savedMap["disableSteamOverlay"] as? Boolean) ?: true,
193195
sdkCloudSaveSubdir = (savedMap["sdkCloudSaveSubdir"] as? String) ?: "",
@@ -233,8 +235,6 @@ data class ContainerData(
233235
sharpnessEffect = (savedMap["sharpnessEffect"] as? String) ?: "None",
234236
sharpnessLevel = (savedMap["sharpnessLevel"] as? Int) ?: 100,
235237
sharpnessDenoise = (savedMap["sharpnessDenoise"] as? Int) ?: 100,
236-
vibrationMode = (savedMap["vibrationMode"] as? String) ?: "controller",
237-
vibrationIntensity = (savedMap["vibrationIntensity"] as? Int) ?: 100,
238238
)
239239
},
240240
)

app/src/main/java/com/winlator/inputcontrols/ControllerManager.java

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111

1212
import app.gamenative.PrefManager;
1313

14+
import com.winlator.winhandler.WinHandler;
15+
1416
import java.util.ArrayList;
1517
import java.util.List;
1618

17-
public class ControllerManager {
19+
public class ControllerManager implements InputManager.InputDeviceListener {
1820

1921
@SuppressLint("StaticFieldLeak")
2022
private static ControllerManager instance;
@@ -44,7 +46,7 @@ private ControllerManager() {
4446
private final SparseArray<String> slotAssignments = new SparseArray<>();
4547

4648
// This tracks which of the 4 player slots are enabled by the user.
47-
private final boolean[] enabledSlots = new boolean[4];
49+
private final boolean[] enabledSlots = new boolean[WinHandler.MAX_PLAYERS];
4850

4951
public static final String PREF_PLAYER_SLOT_PREFIX = "controller_slot_";
5052
public static final String PREF_ENABLED_SLOTS_PREFIX = "enabled_slot_";
@@ -62,6 +64,28 @@ public void init(Context context) {
6264
// On startup, we load saved settings and scan for connected devices.
6365
loadAssignments();
6466
scanForDevices();
67+
68+
// Keep detectedDevices in sync with hot-plug events. null Handler dispatches
69+
// callbacks on the main thread, the same thread that handles input events,
70+
// so detectedDevices needs no synchronization.
71+
inputManager.registerInputDeviceListener(this, null);
72+
73+
// Single-controller correction: if only one controller is connected and it's
74+
// not in slot 0, move it so P1 is always populated for games that may only check P1
75+
if (detectedDevices.size() == 1) {
76+
String id = getDeviceIdentifier(detectedDevices.get(0));
77+
if (id != null && !id.equals(slotAssignments.get(0))) {
78+
// Remove from whatever slot it was in
79+
for (int i = 0; i < WinHandler.MAX_PLAYERS; i++) {
80+
if (id.equals(slotAssignments.get(i))) {
81+
slotAssignments.remove(i);
82+
}
83+
}
84+
slotAssignments.put(0, id);
85+
enabledSlots[0] = true;
86+
saveAssignments();
87+
}
88+
}
6589
}
6690

6791

@@ -82,12 +106,39 @@ public void scanForDevices() {
82106
}
83107
}
84108

109+
@Override
110+
public void onInputDeviceAdded(int deviceId) {
111+
InputDevice device = inputManager.getInputDevice(deviceId);
112+
if (device == null || device.isVirtual() || !isGameController(device)) return;
113+
for (InputDevice existing : detectedDevices) {
114+
if (existing.getId() == deviceId) return;
115+
}
116+
detectedDevices.add(device);
117+
}
118+
119+
@Override
120+
public void onInputDeviceRemoved(int deviceId) {
121+
// getInputDevice returns null for a removed device, so match by id.
122+
for (int i = 0; i < detectedDevices.size(); i++) {
123+
if (detectedDevices.get(i).getId() == deviceId) {
124+
detectedDevices.remove(i);
125+
return;
126+
}
127+
}
128+
}
129+
130+
@Override
131+
public void onInputDeviceChanged(int deviceId) {
132+
onInputDeviceRemoved(deviceId);
133+
onInputDeviceAdded(deviceId);
134+
}
135+
85136
/**
86137
* Loads the saved player slot assignments and enabled states from SharedPreferences.
87138
*/
88139
private void loadAssignments() {
89140
slotAssignments.clear();
90-
for (int i = 0; i < 4; i++) {
141+
for (int i = 0; i < WinHandler.MAX_PLAYERS; i++) {
91142
// Load which device is assigned to this slot
92143
String prefKey = PREF_PLAYER_SLOT_PREFIX + i;
93144
String deviceIdentifier = preferences.getString(prefKey, null);
@@ -106,7 +157,7 @@ private void loadAssignments() {
106157
*/
107158
public void saveAssignments() {
108159
SharedPreferences.Editor editor = preferences.edit();
109-
for (int i = 0; i < 4; i++) {
160+
for (int i = 0; i < WinHandler.MAX_PLAYERS; i++) {
110161
// Save the assigned device identifier
111162
String deviceIdentifier = slotAssignments.get(i);
112163
String prefKey = PREF_PLAYER_SLOT_PREFIX + i;
@@ -202,13 +253,13 @@ public int getEnabledPlayerCount() {
202253
* @param device The physical InputDevice to assign.
203254
*/
204255
public void assignDeviceToSlot(int slotIndex, InputDevice device) {
205-
if (slotIndex < 0 || slotIndex >= 4) return;
256+
if (slotIndex < 0 || slotIndex >= WinHandler.MAX_PLAYERS) return;
206257

207258
String newDeviceIdentifier = getDeviceIdentifier(device);
208259
if (newDeviceIdentifier == null) return;
209260

210261
// First, remove the new device from any slot it might already be in.
211-
for (int i = 0; i < 4; i++) {
262+
for (int i = 0; i < WinHandler.MAX_PLAYERS; i++) {
212263
if (newDeviceIdentifier.equals(slotAssignments.get(i))) {
213264
slotAssignments.remove(i);
214265
}
@@ -224,7 +275,7 @@ public void assignDeviceToSlot(int slotIndex, InputDevice device) {
224275
* @param slotIndex The player slot to un-assign (0-3).
225276
*/
226277
public void unassignSlot(int slotIndex) {
227-
if (slotIndex < 0 || slotIndex >= 4) return;
278+
if (slotIndex < 0 || slotIndex >= WinHandler.MAX_PLAYERS) return;
228279
slotAssignments.remove(slotIndex);
229280
saveAssignments();
230281
}
@@ -277,13 +328,58 @@ public InputDevice getAssignedDeviceForSlot(int slotIndex) {
277328
* @param isEnabled The new enabled state.
278329
*/
279330
public void setSlotEnabled(int slotIndex, boolean isEnabled) {
280-
if (slotIndex < 0 || slotIndex >= 4) return;
331+
if (slotIndex < 0 || slotIndex >= WinHandler.MAX_PLAYERS) return;
281332
enabledSlots[slotIndex] = isEnabled;
282333
saveAssignments();
283334
}
284335

285336
public boolean isSlotEnabled(int slotIndex) {
286-
if (slotIndex < 0 || slotIndex >= 4) return false;
337+
if (slotIndex < 0 || slotIndex >= WinHandler.MAX_PLAYERS) return false;
287338
return enabledSlots[slotIndex];
288339
}
340+
341+
/**
342+
* Auto-assigns a device to the first available slot.
343+
* If the device is already assigned, returns its existing slot.
344+
* When only one controller is connected, it always gets slot 0 (P1) — this
345+
* prevents stale multi-controller assignments from stranding a single controller
346+
* in a non-P1 slot where games won't see it.
347+
* @param deviceId The Android device ID from the input event.
348+
* @return The slot index (0-3), or -1 if no slot available or device is not a controller.
349+
*/
350+
public int autoAssignDevice(int deviceId) {
351+
InputDevice device = inputManager.getInputDevice(deviceId);
352+
if (device == null || !isGameController(device)) return -1;
353+
354+
int existingSlot = getSlotForDevice(deviceId);
355+
356+
// Single-controller fast path: if this is the only connected controller and
357+
// it's not already in slot 0, move it there so P1 is always populated.
358+
// Also require that detectedDevices actually contains the current device — without
359+
// this, a stale cache (e.g. P2 just plugged in but onInputDeviceAdded hasn't fired
360+
// yet) would see size==1 from the previous device and incorrectly evict P1 from slot 0.
361+
if (detectedDevices.size() == 1 && detectedDevices.get(0).getId() == deviceId && existingSlot != 0) {
362+
String deviceIdentifier = getDeviceIdentifier(device);
363+
if (existingSlot >= 0) {
364+
slotAssignments.remove(existingSlot);
365+
}
366+
slotAssignments.put(0, deviceIdentifier);
367+
enabledSlots[0] = true;
368+
saveAssignments();
369+
return 0;
370+
}
371+
372+
if (existingSlot >= 0) {
373+
return isSlotEnabled(existingSlot) ? existingSlot : -1;
374+
}
375+
376+
for (int i = 0; i < WinHandler.MAX_PLAYERS; i++) {
377+
if (slotAssignments.get(i) == null) {
378+
assignDeviceToSlot(i, device);
379+
setSlotEnabled(i, true);
380+
return i;
381+
}
382+
}
383+
return -1;
384+
}
289385
}

0 commit comments

Comments
 (0)