Skip to content

Add PC-accurate controller vibration with per-container settings#1214

Open
TideGear wants to merge 1 commit intoutkarshdalal:masterfrom
TideGear:Fix-Vibration
Open

Add PC-accurate controller vibration with per-container settings#1214
TideGear wants to merge 1 commit intoutkarshdalal:masterfrom
TideGear:Fix-Vibration

Conversation

@TideGear
Copy link
Copy Markdown

@TideGear TideGear commented Apr 14, 2026

...or as accurate as I'm smart enough to achieve.

Adds full controller vibration support that mirrors PC behavior — games trigger the physical controller's rumble motors instead of (or in addition to) the Android device vibrator.

What's included:

  • Per-container vibration settings — mode selector (Off / Controller / Device / Both) and intensity slider (0–100%) in the Controller tab, persisted via ContainerData and PrefManager
  • Dual-motor (non-collapsed) rumble — uses VibratorManager (API 31+) to drive low-frequency and high-frequency motors independently on DualShock 4, DualSense, and XInput controllers; falls back to blended single-vibrator on older APIs
  • SDL rumble keepalive in evshim — Wine/XInput sends short-duration rumble that SDL auto-expires after ~1 s; evshim now periodically re-sends the last non-zero values to reset SDL's internal timer, preserving "set and forget" semantics for sustained vibrations
  • Shared memory read fix — changed read()pread(..., 0) in vjoy_updater so the polling loop always reads from offset 0 instead of advancing past the data after one iteration (this was silently breaking button/axis forwarding)
  • NDK-compiled evshim bundled in APK — added minimal SDL2 stub headers, CMake target, and deploy-on-launch copy in BionicProgramLauncherComponent so evshim updates ship with the APK rather than requiring imagefs rebuilds
  • Tested with: DualShock 4 (BT), DualSense (BT + USB), 8BitDo Pro 2 in XInput mode (BT)

Known limitation: XInput controller vibration does not currently work over USB — this is an Android platform limitation that will be addressed in a follow-up.

Recording

https://photos.app.goo.gl/uRuX5h6xcWU9iZHf9

Checklist

  • If I have access to #code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.
  • I have attached a recording of the change.
  • I have read and agree to the contribution guidelines in CONTRIBUTING.md.

Summary by cubic

Adds PC-accurate controller vibration with per-container settings. Supports DualShock 4, DualSense, and XInput over Bluetooth; XInput over USB is not yet supported.

  • New Features

    • Per-container vibration settings: Off / Controller / Device / Both + 0–100% intensity; persisted via ContainerData/PrefManager and exposed in the Controller tab.
    • Dual-motor rumble using VibratorManager (API 31+) with fallback to blended single motor on older APIs; per-player rumble with SDL keepalive to match PC/XInput “set and forget” behavior.
    • Bundle NDK-built libevshim.so in the APK and auto-deploy on launch.
  • Bug Fixes

    • Read shared memory with pread(..., 0) to avoid advancing past the buffer, fixing lost button/axis updates and rumble reads.

Written for commit 0998922. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Added vibration configuration settings to controller preferences, allowing users to select vibration mode (Off, Controller, Device, or Both) and adjust vibration intensity via a 0-100 slider
    • Enhanced rumble support with improved vibration handling across multiple input devices and controllers

Vibration works over Bluetooth with DualShock 4, DualSense, and XInput controllers (8BitDo Pro 2 tested). For XInput controllers, vibration does not currently work over USB.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 14, 2026

📝 Walkthrough

Walkthrough

This PR introduces comprehensive vibration/rumble configuration support across the application stack. It adds a native evshim shared library with per-player rumble keepalive logic, exposes vibration mode and intensity settings through preferences and container configuration, provides UI controls for vibration customization, and refactors WinHandler to support multi-mode vibration (controller, device, both, or off) with per-player state tracking and intensity scaling.

Changes

Cohort / File(s) Summary
C/C++ Build & SDL Stub
app/src/main/cpp/extras/CMakeLists.txt, app/src/main/cpp/extras/evshim.c, app/src/main/cpp/extras/sdl2_stub/SDL2/SDL.h
Added evshim shared library build target, expanded evshim.c with dynamic SDL_JoystickRumble loading, per-player rumble state tracking, and periodic keepalive re-sends using pread() to avoid shared file offset issues. New SDL2 stub header provides minimal type stubs, macros, and struct definitions including SDL_VirtualJoystickDesc with function pointers.
Vibration Preferences & Configuration
app/src/main/java/app/gamenative/PrefManager.kt, app/src/main/java/com/winlator/container/ContainerData.kt, app/src/main/java/app/gamenative/utils/ContainerUtils.kt
Added vibrationMode (string, default "controller") and vibrationIntensity (int 0–100, default 100) to preferences and container configuration, with integration into container data persistence and extras mapping.
Vibration UI Controls
app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt
Added dropdown menu to select vibration mode (Off, Controller, Device, Both), conditional slider for intensity (0–100) when mode is not "off", with state updates via config.copy(...).
Core Vibration Handler Refactor
app/src/main/java/com/winlator/winhandler/WinHandler.java
Replaced single global vibration state with per-player arrays for tracking frequency and keepalive counters. Added configuration APIs (setVibrationMode, setVibrationIntensity), multi-mode support ("controller", "device", "both", "off"), per-player vibration methods, device resolution logic (resolveInputDeviceForPlayer), and Android vibrator integration (VibratorManager for SDK≥31) with amplitude scaling.
Integration Points
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt, app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java
XServerScreen wires vibration mode/intensity from container extras to WinHandler during launch. BionicProgramLauncherComponent deploys libevshim.so to guest image with executable permissions and updates LD_PRELOAD composition.
String Resources
app/src/main/res/values/strings.xml
Added UI string resources for vibration_mode and vibration_intensity labels.

Sequence Diagram

sequenceDiagram
    participant UI as UI (ControllerTab)
    participant Config as Config<br/>(ContainerData)
    participant WinHandler as WinHandler
    participant PlayerDevice as InputDevice<br/>(Player)
    participant Vibrator as Android Vibrator
    participant EVShim as EVShim (native)

    UI->>Config: User selects vibration mode & intensity
    Config->>Config: Store mode/intensity settings
    Config->>WinHandler: setVibrationMode(mode)<br/>setVibrationIntensity(intensity)
    WinHandler->>WinHandler: Update vibrationMode & vibrationIntensity fields

    Note over EVShim,Vibrator: Runtime Rumble Poll Loop
    EVShim->>EVShim: Read per-player rumble frequencies from buffer
    EVShim->>WinHandler: Poll rumble state for each player
    
    alt Mode: "controller" or "both"
        WinHandler->>PlayerDevice: resolveInputDeviceForPlayer(player)
        WinHandler->>WinHandler: startVibrationForPlayer()<br/>with intensity scaling
        WinHandler->>PlayerDevice: Vibrate controller
    end
    
    alt Mode: "device" or "both"
        WinHandler->>Vibrator: VibratorManager.vibrate()<br/>with CombinedVibration (SDK≥31)
        Vibrator->>Vibrator: Scale amplitude by intensity %
    end
    
    EVShim->>EVShim: Periodic keepalive:<br/>Re-send non-zero rumble<br/>every KEEPALIVE_DUR_MS
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly Related PRs

Suggested Reviewers

  • phobos665

Poem

🐰 A rumble and a vibrate, hooray!
Per-player shakes now have their way,
Controller, device, or both combined,
Intensity scaled with care refined,
EVShim dances, keepalive plays—
Haptic joy for all our days! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main feature—PC-accurate controller vibration—and mentions the key addition of per-container settings.
Description check ✅ Passed The description is comprehensive and well-structured, covering all required sections: objectives, technical details, recording, and completed checklist items.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch Fix-Vibration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 12 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/PrefManager.kt">

<violation number="1" location="app/src/main/java/app/gamenative/PrefManager.kt:223">
P2: `vibrationMode` persists arbitrary strings despite a closed set of supported modes, creating a cross-file contract bug where invalid values silently disable vibration behavior.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt:75">
P2: New user-visible vibration mode labels are hardcoded in Compose instead of string resources, breaking localization/resource centralization.</violation>
</file>

<file name="app/src/main/cpp/extras/evshim.c">

<violation number="1" location="app/src/main/cpp/extras/evshim.c:163">
P1: Unsynchronized shared keepalive rumble state (`last_rumble_low/high`) is read/written across callback/updater contexts, creating a C data race (undefined behavior).</violation>
</file>

<file name="app/src/main/java/com/winlator/winhandler/WinHandler.java">

<violation number="1" location="app/src/main/java/com/winlator/winhandler/WinHandler.java:776">
P2: Rumble routing falls back to the first detected gamepad, which can vibrate the wrong controller when multiple devices are connected.</violation>

<violation number="2" location="app/src/main/java/com/winlator/winhandler/WinHandler.java:876">
P1: Stopping rumble depends on current mode, so changing mode can skip canceling previously-started device vibration (up to 60s one-shot).</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

* XInput "set and forget" semantics through the SDL translation. */
if (p_SDL_JoystickRumble && ++keepalive_ctr >= RUMBLE_KEEPALIVE_TICKS) {
keepalive_ctr = 0;
uint16_t kl = last_rumble_low[idx];
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Unsynchronized shared keepalive rumble state (last_rumble_low/high) is read/written across callback/updater contexts, creating a C data race (undefined behavior).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/cpp/extras/evshim.c, line 163:

<comment>Unsynchronized shared keepalive rumble state (`last_rumble_low/high`) is read/written across callback/updater contexts, creating a C data race (undefined behavior).</comment>

<file context>
@@ -134,6 +155,19 @@ static void *vjoy_updater(void *arg)
+         * XInput "set and forget" semantics through the SDL translation. */
+        if (p_SDL_JoystickRumble && ++keepalive_ctr >= RUMBLE_KEEPALIVE_TICKS) {
+            keepalive_ctr = 0;
+            uint16_t kl = last_rumble_low[idx];
+            uint16_t kh = last_rumble_high[idx];
+            if (kl != 0 || kh != 0) {
</file context>
Fix with Cubic

if (phoneVibrator != null) {
phoneVibrator.cancel();

if (!"controller".equals(vibrationMode)) {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Stopping rumble depends on current mode, so changing mode can skip canceling previously-started device vibration (up to 60s one-shot).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/com/winlator/winhandler/WinHandler.java, line 876:

<comment>Stopping rumble depends on current mode, so changing mode can skip canceling previously-started device vibration (up to 60s one-shot).</comment>

<file context>
@@ -607,65 +650,241 @@ private void startRumblePoller() {
-        if (phoneVibrator != null) {
-            phoneVibrator.cancel();
+
+        if (!"controller".equals(vibrationMode)) {
+            try {
+                Vibrator phoneVibrator = (Vibrator) activity.getSystemService(Context.VIBRATOR_SERVICE);
</file context>
Fix with Cubic

var vibrationMode: String
get() = getPref(VIBRATION_MODE, "controller")
set(value) {
setPref(VIBRATION_MODE, value)
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: vibrationMode persists arbitrary strings despite a closed set of supported modes, creating a cross-file contract bug where invalid values silently disable vibration behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/PrefManager.kt, line 223:

<comment>`vibrationMode` persists arbitrary strings despite a closed set of supported modes, creating a cross-file contract bug where invalid values silently disable vibration behavior.</comment>

<file context>
@@ -216,6 +216,20 @@ object PrefManager {
+    var vibrationMode: String
+        get() = getPref(VIBRATION_MODE, "controller")
+        set(value) {
+            setPref(VIBRATION_MODE, value)
+        }
+
</file context>
Suggested change
setPref(VIBRATION_MODE, value)
setPref(VIBRATION_MODE, when (value.lowercase()) {
"off", "controller", "device", "both" -> value.lowercase()
else -> "controller"
})
Fix with Cubic

state.config.value = config.copy(dinputMapperType = if (index == 0) 1 else 2)
},
)
val vibrationModes = listOf("Off", "Controller", "Device", "Both")
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: New user-visible vibration mode labels are hardcoded in Compose instead of string resources, breaking localization/resource centralization.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt, line 75:

<comment>New user-visible vibration mode labels are hardcoded in Compose instead of string resources, breaking localization/resource centralization.</comment>

<file context>
@@ -65,6 +72,36 @@ fun ControllerTabContent(state: ContainerConfigState, default: Boolean) {
                 state.config.value = config.copy(dinputMapperType = if (index == 0) 1 else 2)
             },
         )
+        val vibrationModes = listOf("Off", "Controller", "Device", "Both")
+        val vibrationModeValues = listOf("off", "controller", "device", "both")
+        val vibrationModeIndex = vibrationModeValues.indexOf(config.vibrationMode).coerceAtLeast(0)
</file context>
Fix with Cubic

if (player == 0) {
List<InputDevice> detected = controllerManager.getDetectedDevices();
if (!detected.isEmpty()) {
return detected.get(0);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Rumble routing falls back to the first detected gamepad, which can vibrate the wrong controller when multiple devices are connected.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/com/winlator/winhandler/WinHandler.java, line 776:

<comment>Rumble routing falls back to the first detected gamepad, which can vibrate the wrong controller when multiple devices are connected.</comment>

<file context>
@@ -607,65 +650,241 @@ private void startRumblePoller() {
+        if (player == 0) {
+            List<InputDevice> detected = controllerManager.getDetectedDevices();
+            if (!detected.isEmpty()) {
+                return detected.get(0);
+            }
+        }
</file context>
Suggested change
return detected.get(0);
return null;
Fix with Cubic

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
app/src/main/java/app/gamenative/PrefManager.kt (1)

219-231: Normalize vibration preference values at the persistence boundary.

vibrationMode currently persists arbitrary strings, and vibrationIntensity is only bounded on write. Normalizing on read+write avoids undefined behavior from stale/corrupt prefs.

♻️ Suggested hardening patch
+    private val VALID_VIBRATION_MODES = setOf("off", "controller", "device", "both")
+
     private val VIBRATION_MODE = stringPreferencesKey("vibration_mode")
     var vibrationMode: String
-        get() = getPref(VIBRATION_MODE, "controller")
+        get() = getPref(VIBRATION_MODE, "controller")
+            .takeIf { it in VALID_VIBRATION_MODES } ?: "controller"
         set(value) {
-            setPref(VIBRATION_MODE, value)
+            setPref(VIBRATION_MODE, value.takeIf { it in VALID_VIBRATION_MODES } ?: "controller")
         }
 
     private val VIBRATION_INTENSITY = intPreferencesKey("vibration_intensity")
     var vibrationIntensity: Int
-        get() = getPref(VIBRATION_INTENSITY, 100)
+        get() = getPref(VIBRATION_INTENSITY, 100).coerceIn(0, 100)
         set(value) {
             setPref(VIBRATION_INTENSITY, value.coerceIn(0, 100))
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/PrefManager.kt` around lines 219 - 231,
Persisted vibration preferences aren't normalized on read and only partially
bounded on write, so stale or corrupt values can cause undefined behavior;
update vibrationMode and vibrationIntensity accessors to validate and normalize
both on get and set: for vibrationMode (symbol VIBRATION_MODE and property
vibrationMode) accept only a defined set of allowed values (e.g., include
"controller" as the default) and on get return the default if stored value is
missing/unknown, and on set constrain input to the allowed set before calling
setPref; for vibrationIntensity (symbol VIBRATION_INTENSITY and property
vibrationIntensity) apply value.coerceIn(0,100) on both set and when reading
from getPref (or fallback to the default 100 if stored value is out of range) so
getPref/getters never return invalid values; use existing getPref and setPref
helpers to centralize this normalization.
app/src/main/java/com/winlator/winhandler/WinHandler.java (1)

104-107: Validate vibrationMode before storing it.

This is persisted as a free-form string, and an unexpected value or casing currently falls through every branch in startVibrationForPlayer() while still marking the slot as rumbling. Normalizing to the supported set here avoids silent no-op sessions from malformed container data.

Suggested normalization
 public void setVibrationMode(String mode) {
-    this.vibrationMode = mode != null ? mode : "controller";
+    String normalized = mode == null ? "controller" : mode.toLowerCase(java.util.Locale.ROOT);
+    switch (normalized) {
+        case "off":
+        case "controller":
+        case "device":
+        case "both":
+            this.vibrationMode = normalized;
+            break;
+        default:
+            Log.w(TAG, "Unknown vibration mode '" + mode + "', defaulting to controller");
+            this.vibrationMode = "controller";
+            break;
+    }
     Log.i(TAG, "Vibration mode set to: " + this.vibrationMode);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/winlator/winhandler/WinHandler.java` around lines 104 -
107, Validate and normalize the incoming vibrationMode in setVibrationMode
before storing: create a canonical set (e.g., SUPPORTED_VIBRATION_MODES) and
normalize the input (trim + toLowerCase) and map known aliases to canonical
values; if the normalized value is not in SUPPORTED_VIBRATION_MODES, fall back
to "controller" and log the normalization. Update setVibrationMode to use this
check/mapping so vibrationMode only ever contains a supported value and
startVibrationForPlayer will not receive unexpected free-form strings.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/cpp/extras/evshim.c`:
- Around line 78-90: OnRumble() is writing last_rumble_low/last_rumble_high
concurrently with vjoy_updater() reading them, causing a data race; acquire
shm_mutex around updates and the pwrite snapshot so the pair of low/high values
are written atomically (i.e., lock shm_mutex, update last_rumble_low[idx] and
last_rumble_high[idx] and prepare vals[], call pwrite while still holding the
mutex, then unlock), but move any call to SDL_JoystickRumble() to after
unlocking; apply the same mutex-protected snapshot fix to the other occurrence
that updates last_rumble_* (the block referenced at lines 161-167) so both sites
use shm_mutex for consistent reads/writes.

In `@app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt`:
- Around line 77-104: The UI shows a mismatch because vibrationModeIndex is
derived from vibrationModeValues using config.vibrationMode but the slider
visibility checks the raw config.vibrationMode; normalize the vibration mode
once (e.g., map/normalize config.vibrationMode to a valid value from
vibrationModeValues) before using it in the UI and persist that normalized value
back into state.config (or use a local normalizedMode for both the dropdown and
the slider visibility check). Update the logic around vibrationModeIndex, the
remember block for intensitySlider, and the conditional "if
(config.vibrationMode != \"off\")" to use the normalized value (reference:
vibrationModeIndex, vibrationModeValues, config.vibrationMode,
state.config.value, intensitySlider).

In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt`:
- Around line 1546-1547: The code passes raw extras to
WinHandler.setVibrationMode via
handler.setVibrationMode(container.getExtra("vibrationMode", "controller")),
which can store variants like "Controller" and later break
startVibrationForPlayer that only recognizes "off/controller/device/both";
normalize the value before calling setVibrationMode (e.g. read
container.getExtra("vibrationMode", "controller"), trim and toLowerCase it, then
map common synonyms/capitalizations to the canonical tokens "off", "controller",
"device", or "both") so WinHandler.setVibrationMode always receives one of the
accepted lowercase values.

In `@app/src/main/java/app/gamenative/utils/ContainerUtils.kt`:
- Around line 146-147: The new container creation is bypassing the updated
defaults: update createNewContainer(...) so the ContainerData it constructs
includes vibrationMode and vibrationIntensity from PrefManager (or simply
initialize it from getDefaultContainerData()) instead of hardcoding
"controller"/100; modify the ContainerData construction inside
createNewContainer to set vibrationMode = PrefManager.vibrationMode and
vibrationIntensity = PrefManager.vibrationIntensity (or replace the manual
builder with a call to getDefaultContainerData()) to ensure fresh containers
respect saved defaults.

In `@app/src/main/java/com/winlator/winhandler/WinHandler.java`:
- Around line 616-645: The keepalive logic is re-triggering the 60s phone
one-shot because unchanged rumble calls startVibrationForPlayer(p, lowFreq,
highFreq) which routes "device"/"both" into vibrateDevice(); change the
keepalive path so device vibration is not restarted every keepalive tick—either
add a new method like refreshDeviceVibrationKeepalive(playerId, lowFreq,
highFreq) or add a flag/overload to startVibrationForPlayer to distinguish
initial/start vs keepalive; ensure the initial start still calls vibrateDevice()
with DEVICE_RUMBLE_MS but keepalive calls a separate device refresh with a much
longer cadence (or a no-op for device) and adjust rumbleKeepaliveCtr handling
accordingly so controller rumble continues to be refreshed while device
vibration is not restarted every ~240ms.

---

Nitpick comments:
In `@app/src/main/java/app/gamenative/PrefManager.kt`:
- Around line 219-231: Persisted vibration preferences aren't normalized on read
and only partially bounded on write, so stale or corrupt values can cause
undefined behavior; update vibrationMode and vibrationIntensity accessors to
validate and normalize both on get and set: for vibrationMode (symbol
VIBRATION_MODE and property vibrationMode) accept only a defined set of allowed
values (e.g., include "controller" as the default) and on get return the default
if stored value is missing/unknown, and on set constrain input to the allowed
set before calling setPref; for vibrationIntensity (symbol VIBRATION_INTENSITY
and property vibrationIntensity) apply value.coerceIn(0,100) on both set and
when reading from getPref (or fallback to the default 100 if stored value is out
of range) so getPref/getters never return invalid values; use existing getPref
and setPref helpers to centralize this normalization.

In `@app/src/main/java/com/winlator/winhandler/WinHandler.java`:
- Around line 104-107: Validate and normalize the incoming vibrationMode in
setVibrationMode before storing: create a canonical set (e.g.,
SUPPORTED_VIBRATION_MODES) and normalize the input (trim + toLowerCase) and map
known aliases to canonical values; if the normalized value is not in
SUPPORTED_VIBRATION_MODES, fall back to "controller" and log the normalization.
Update setVibrationMode to use this check/mapping so vibrationMode only ever
contains a supported value and startVibrationForPlayer will not receive
unexpected free-form strings.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2ea17b0f-27f1-4d46-b5d7-819da35201fa

📥 Commits

Reviewing files that changed from the base of the PR and between a29bb89 and 0998922.

⛔ Files ignored due to path filters (1)
  • app/src/main/jniLibs/arm64-v8a/libevshim.so is excluded by !**/*.so
📒 Files selected for processing (11)
  • app/src/main/cpp/extras/CMakeLists.txt
  • app/src/main/cpp/extras/evshim.c
  • app/src/main/cpp/extras/sdl2_stub/SDL2/SDL.h
  • app/src/main/java/app/gamenative/PrefManager.kt
  • app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt
  • app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
  • app/src/main/java/app/gamenative/utils/ContainerUtils.kt
  • app/src/main/java/com/winlator/container/ContainerData.kt
  • app/src/main/java/com/winlator/winhandler/WinHandler.java
  • app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java
  • app/src/main/res/values/strings.xml

Comment on lines +78 to +90
if (low_frequency_rumble != 0 || high_frequency_rumble != 0) {
last_rumble_low [idx] = low_frequency_rumble;
last_rumble_high[idx] = high_frequency_rumble;
} else {
last_rumble_low [idx] = 0;
last_rumble_high[idx] = 0;
}

uint16_t vals[2] = { low_frequency_rumble, high_frequency_rumble };

pthread_mutex_lock(&shm_mutex); /* NEW */
pthread_mutex_lock(&shm_mutex);
ssize_t w = pwrite(rumble_fd[idx], vals, sizeof(vals), 32);
pthread_mutex_unlock(&shm_mutex); /* NEW */
pthread_mutex_unlock(&shm_mutex);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Protect last_rumble_* with the same synchronization as the keepalive reader.

OnRumble() now updates these arrays from one thread while vjoy_updater() reads them later for keepalive from another. In C that's a real data race, so the keepalive can replay stale or mixed low/high values. Take the snapshot under the mutex, then call SDL_JoystickRumble() after releasing it.

🔧 Suggested fix
-    if (low_frequency_rumble != 0 || high_frequency_rumble != 0) {
-        last_rumble_low [idx] = low_frequency_rumble;
-        last_rumble_high[idx] = high_frequency_rumble;
-    } else {
-        last_rumble_low [idx] = 0;
-        last_rumble_high[idx] = 0;
-    }
-
     uint16_t vals[2] = { low_frequency_rumble, high_frequency_rumble };
 
     pthread_mutex_lock(&shm_mutex);
+    last_rumble_low[idx] = low_frequency_rumble;
+    last_rumble_high[idx] = high_frequency_rumble;
     ssize_t w = pwrite(rumble_fd[idx], vals, sizeof(vals), 32);
     pthread_mutex_unlock(&shm_mutex);
@@
-            uint16_t kl = last_rumble_low[idx];
-            uint16_t kh = last_rumble_high[idx];
+            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) {
                 p_SDL_JoystickRumble(js, kl, kh, RUMBLE_KEEPALIVE_DUR_MS);

Also applies to: 161-167

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/cpp/extras/evshim.c` around lines 78 - 90, OnRumble() is writing
last_rumble_low/last_rumble_high concurrently with vjoy_updater() reading them,
causing a data race; acquire shm_mutex around updates and the pwrite snapshot so
the pair of low/high values are written atomically (i.e., lock shm_mutex, update
last_rumble_low[idx] and last_rumble_high[idx] and prepare vals[], call pwrite
while still holding the mutex, then unlock), but move any call to
SDL_JoystickRumble() to after unlocking; apply the same mutex-protected snapshot
fix to the other occurrence that updates last_rumble_* (the block referenced at
lines 161-167) so both sites use shm_mutex for consistent reads/writes.

Comment on lines +77 to +104
val vibrationModeIndex = vibrationModeValues.indexOf(config.vibrationMode).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 (config.vibrationMode != "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%")
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize the stored vibration mode once before deriving UI state.

indexOf(...).coerceAtLeast(0) makes mixed-case or unknown values render as Off, but the slider branch still checks the raw config.vibrationMode. That yields contradictory UI for stale/imported configs: Off is selected while the intensity slider is still visible.

🔧 Suggested fix
-        val vibrationModeIndex = vibrationModeValues.indexOf(config.vibrationMode).coerceAtLeast(0)
+        val normalizedVibrationMode = config.vibrationMode
+            .lowercase()
+            .takeIf { it in vibrationModeValues }
+            ?: "off"
+        val vibrationModeIndex = vibrationModeValues.indexOf(normalizedVibrationMode)
@@
-        if (config.vibrationMode != "off") {
+        if (normalizedVibrationMode != "off") {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val vibrationModeIndex = vibrationModeValues.indexOf(config.vibrationMode).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 (config.vibrationMode != "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%")
}
}
val normalizedVibrationMode = config.vibrationMode
.lowercase()
.takeIf { it in vibrationModeValues }
?: "off"
val vibrationModeIndex = vibrationModeValues.indexOf(normalizedVibrationMode)
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%")
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt` around
lines 77 - 104, The UI shows a mismatch because vibrationModeIndex is derived
from vibrationModeValues using config.vibrationMode but the slider visibility
checks the raw config.vibrationMode; normalize the vibration mode once (e.g.,
map/normalize config.vibrationMode to a valid value from vibrationModeValues)
before using it in the UI and persist that normalized value back into
state.config (or use a local normalizedMode for both the dropdown and the slider
visibility check). Update the logic around vibrationModeIndex, the remember
block for intensitySlider, and the conditional "if (config.vibrationMode !=
\"off\")" to use the normalized value (reference: vibrationModeIndex,
vibrationModeValues, config.vibrationMode, state.config.value, intensitySlider).

Comment on lines +1546 to +1547
handler.setVibrationMode(container.getExtra("vibrationMode", "controller"))
handler.setVibrationIntensity(container.getExtra("vibrationIntensity", "100").toIntOrNull() ?: 100)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize vibrationMode before handing it to WinHandler.

WinHandler.setVibrationMode() only falls back when the value is null, and startVibrationForPlayer() later recognizes only off/controller/device/both. A stale or imported extra like "Controller" will therefore be stored verbatim and silently disable rumble.

🔧 Suggested fix
-                            handler.setVibrationMode(container.getExtra("vibrationMode", "controller"))
+                            val supportedVibrationModes = setOf("off", "controller", "device", "both")
+                            val vibrationMode = container.getExtra("vibrationMode", "controller")
+                                .lowercase(Locale.ROOT)
+                                .takeIf { it in supportedVibrationModes }
+                                ?: "controller"
+                            handler.setVibrationMode(vibrationMode)
                             handler.setVibrationIntensity(container.getExtra("vibrationIntensity", "100").toIntOrNull() ?: 100)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt` around
lines 1546 - 1547, The code passes raw extras to WinHandler.setVibrationMode via
handler.setVibrationMode(container.getExtra("vibrationMode", "controller")),
which can store variants like "Controller" and later break
startVibrationForPlayer that only recognizes "off/controller/device/both";
normalize the value before calling setVibrationMode (e.g. read
container.getExtra("vibrationMode", "controller"), trim and toLowerCase it, then
map common synonyms/capitalizations to the canonical tokens "off", "controller",
"device", or "both") so WinHandler.setVibrationMode always receives one of the
accepted lowercase values.

Comment on lines +146 to +147
vibrationMode = PrefManager.vibrationMode,
vibrationIntensity = PrefManager.vibrationIntensity,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

New containers still ignore the saved vibration defaults.

This updates getDefaultContainerData(), but Line 811 still builds ContainerData manually without vibrationMode or vibrationIntensity. Fresh containers will therefore save "controller" / 100 instead of the current PrefManager values.

Suggested fix in createNewContainer(...)
                 portraitMode = PrefManager.portraitMode,
                 externalDisplayMode = PrefManager.externalDisplayInputMode,
                 externalDisplaySwap = PrefManager.externalDisplaySwap,
+                vibrationMode = PrefManager.vibrationMode,
+                vibrationIntensity = PrefManager.vibrationIntensity,
             )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
vibrationMode = PrefManager.vibrationMode,
vibrationIntensity = PrefManager.vibrationIntensity,
portraitMode = PrefManager.portraitMode,
externalDisplayMode = PrefManager.externalDisplayInputMode,
externalDisplaySwap = PrefManager.externalDisplaySwap,
vibrationMode = PrefManager.vibrationMode,
vibrationIntensity = PrefManager.vibrationIntensity,
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/utils/ContainerUtils.kt` around lines 146 -
147, The new container creation is bypassing the updated defaults: update
createNewContainer(...) so the ContainerData it constructs includes
vibrationMode and vibrationIntensity from PrefManager (or simply initialize it
from getDefaultContainerData()) instead of hardcoding "controller"/100; modify
the ContainerData construction inside createNewContainer to set vibrationMode =
PrefManager.vibrationMode and vibrationIntensity =
PrefManager.vibrationIntensity (or replace the manual builder with a call to
getDefaultContainerData()) to ensure fresh containers respect saved defaults.

Comment on lines +616 to 645
for (int p = 0; p < MAX_PLAYERS; p++) {
try {
MappedByteBuffer buf = getBufferForPlayer(p);
if (buf == null) continue;
short lowFreq = buf.getShort(32);
short highFreq = buf.getShort(34);
boolean changed = lowFreq != lastLowFreq[p] || highFreq != lastHighFreq[p];
if (changed) {
lastLowFreq[p] = lowFreq;
lastHighFreq[p] = highFreq;
rumbleKeepaliveCtr[p] = 0;
if (lowFreq == 0 && highFreq == 0) {
stopVibration();
stopVibrationForPlayer(p);
} else {
startVibration(lowFreq, highFreq);
startVibrationForPlayer(p, lowFreq, highFreq);
}
} else if (isRumbling[p]) {
rumbleKeepaliveCtr[p]++;
if (rumbleKeepaliveCtr[p] >= RUMBLE_KEEPALIVE_INTERVAL) {
rumbleKeepaliveCtr[p] = 0;
startVibrationForPlayer(p, lowFreq, highFreq);
}
}
} catch (Exception e) {
// Buffer may be unmapped; continue polling
}
} catch (Exception e) {
continue;
}
try {
Thread.sleep(20); // Poll for new commands 50 times per second
Thread.sleep(20);
} catch (InterruptedException e) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keepalive is re-triggering 60s phone vibrations.

Lines 632-636 call startVibrationForPlayer() on unchanged rumble, and Lines 845-849 route "device" / "both" back into vibrateDevice(). Because vibrateDevice() issues a 60000 ms one-shot, sustained rumble in device modes gets restarted every ~240 ms instead of just letting the existing effect run. The controller keepalive needs a separate path from the device vibrator.

Suggested direction
                         } else if (isRumbling[p]) {
                             rumbleKeepaliveCtr[p]++;
                             if (rumbleKeepaliveCtr[p] >= RUMBLE_KEEPALIVE_INTERVAL) {
                                 rumbleKeepaliveCtr[p] = 0;
-                                startVibrationForPlayer(p, lowFreq, highFreq);
+                                if ("controller".equals(vibrationMode) || "both".equals(vibrationMode)) {
+                                    vibrateController(p, lowFreq, highFreq);
+                                }
                             }
                         }

Use a separate, much longer refresh cadence if you still need device vibration to outlive DEVICE_RUMBLE_MS.

Also applies to: 843-849

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/winlator/winhandler/WinHandler.java` around lines 616 -
645, The keepalive logic is re-triggering the 60s phone one-shot because
unchanged rumble calls startVibrationForPlayer(p, lowFreq, highFreq) which
routes "device"/"both" into vibrateDevice(); change the keepalive path so device
vibration is not restarted every keepalive tick—either add a new method like
refreshDeviceVibrationKeepalive(playerId, lowFreq, highFreq) or add a
flag/overload to startVibrationForPlayer to distinguish initial/start vs
keepalive; ensure the initial start still calls vibrateDevice() with
DEVICE_RUMBLE_MS but keepalive calls a separate device refresh with a much
longer cadence (or a no-op for device) and adjust rumbleKeepaliveCtr handling
accordingly so controller rumble continues to be refreshed while device
vibration is not restarted every ~240ms.

@TideGear
Copy link
Copy Markdown
Author

I'll address the conflicts and AI points tonight.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant