Skip to content

feat: Multi controller support#1578

Open
phobos665 wants to merge 18 commits into
utkarshdalal:masterfrom
phobos665:feat/multi-controller-support-without-ui
Open

feat: Multi controller support#1578
phobos665 wants to merge 18 commits into
utkarshdalal:masterfrom
phobos665:feat/multi-controller-support-without-ui

Conversation

@phobos665

@phobos665 phobos665 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Description

Add support for multi-controller support along with correct hot-swapping and correct detach logic.

Some behavioural notes:

  • Disconnecting P1 will move the highest-slot down to lower slot
  • Dynamic updating of the slots as we connect & disconnect controllers.
  • Added debug UI to help with understanding the slotting of the controllers within the winhandler logic.

Recording

Uploading multi-controller-small.mp4…

Type of Change

  • Bug fix
  • Performance / stability improvement
  • Compatibility improvements
  • Other (requires prior approval)

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.
  • This change aligns with the current project scope (core functionality, stability, or performance). If not, it has been explicitly approved beforehand.
  • I have attached a recording of the change.
  • I have read and agree to the contribution guidelines in CONTRIBUTING.md.

Summary by cubic

Adds multi‑controller support for up to 4 players with slot‑aware input, per‑slot rumble, hot‑swap, and an optional in‑game controller debug overlay. SDL virtual pads now attach/detach per slot via a new SHM connected flag so only active slots appear to games.

  • New Features

    • SHM adds per‑slot connected at offset 40; evshim manages up to 4 slots, opens/closes per‑slot SDL joystick handles, attaches/detaches on connected changes, writes neutral state on connect, and applies state only when connected.
    • Slot‑aware input/rumble: WinHandler routes key/motion to assigned slots, maintains per‑slot buffers and rumble pollers, adds sendVirtualGamepadState(state, slot), marks slots connected only when a controller or on‑screen pad is active, and clears disconnected slots.
    • Hotplug/auto‑assign: MainActivity and XServerScreen register InputManager listeners and refresh mappings on hot‑plug/show‑hide; ControllerManager auto‑assigns devices, prefers last/recently freed slots, accepts built‑in controllers, caches identifiers, and notifies listeners.
    • UI: New toggle to show a “Controller Debug Menu”; in‑game overlay displays P1–P4 mapping, device IDs/sources, and preferred guest API. Touch controls route to P1 when visible.
    • Bionic launcher opens P1–P4 SHM paths and sets EVSHIM_MAX_PLAYERS=4.
  • Bug Fixes

    • Correctly detaches SDL virtual joysticks by closing handles and calling SDL_JoystickDetachVirtual to prevent ghost devices and leaks.
    • Only active slots are reported to SDL; disconnected slots are cleared and ignored by updates.
    • Rumble targets the correct slot/device with amplitude capping and stops on slot change/disconnect; phone fallback remains.

Written for commit 9a93cf5. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Expanded controller/gamepad support up to 4 simultaneous players with slot-aware event routing and virtual gamepad updates.
    • Improved hotplug/connect-disconnect handling by integrating system input events and auto-assigning devices to slots.
    • Added an optional in-game controller slots debug overlay, toggleable via a new preference (with localized UI text).
  • Bug Fixes
    • Improved shared-memory state validation with clearer error logging and safer early exits.
    • Refined virtual joystick/VJoy connection lifecycle for more reliable attach/detach behavior and correct shared-memory sizing.

@phobos665 phobos665 requested a review from utkarshdalal as a code owner June 12, 2026 12:17
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR extends the controller framework to support up to 4 concurrent gamepads instead of 1. Changes span from Java constants and ControllerManager device-to-slot mapping through WinHandler per-slot state management, input routing in XServerScreen, rumble lifecycle handling, shared-memory writing, and native-side player initialization and virtual joystick attachment/detachment, ensuring slot-aware behavior across the entire stack. Localization strings for a new controller debug menu are added across 15 languages.

Changes

Multi-Controller Player Support (1–4 Players)

Layer / File(s) Summary
Multi-controller infrastructure constants
app/src/main/java/com/winlator/winhandler/WinHandler.java, app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java
WinHandler.MAX_PLAYERS increases from 1 to 4; BionicProgramLauncherComponent.execGuestProgram() now creates gamepad memory files for all 4 slots and sets EVSHIM_MAX_PLAYERS from the expanded constant instead of a local counter.
ControllerManager device-to-slot mapping
app/src/main/java/com/winlator/inputcontrols/ControllerManager.java
Device identifier caching via knownDeviceIdentifiers, thread-safe listener notifications for slot changes via OnSlotsChangedListener, generalized slot loops using MAX_SLOTS constant, and assignment/unassignment flows that validate bounds, persist changes, mark recently-freed slots, and auto-assign new devices to preferred free slots.
MainActivity InputManager device listener
app/src/main/java/app/gamenative/MainActivity.kt
Adds InputManager import and registers an InputDeviceListener during onCreate that forwards device add/change events to ControllerManager.onDeviceConnected and device remove to onDeviceDisconnected; unregisters the listener during onDestroy.
WinHandler per-slot state structures
app/src/main/java/com/winlator/winhandler/WinHandler.java
Single-controller rumble fields are replaced with per-slot arrays for rumble poller threads, device IDs, last rumble values, and shared-memory file handles; construction initializes all device IDs to -1; adds describeDevice() logging helper and refactors controller mapping refresh with parameterized private helper and public entrypoints.
Native evshim virtual joystick state and lifecycle
app/src/main/cpp/evshim/evshim.c
Adds vjoy_handles array and sdl_vjoy_lock mutex for managing SDL virtual joystick handles per-slot; introduces attach_vjoy, detach_vjoy, and set_vjoy_connected helpers; initialize_wine() requires SDL_JoystickDetachVirtual and SDL_JoystickClose symbols; vjoy_updater threads now track shm[idx]->connected changes and attach/detach the SDL virtual joystick accordingly.
Native evshim player initialization and bounds checking
app/src/main/cpp/evshim/evshim.c
initialize_all_pads() defaults players to 1 for Wine and MAX_GAMEPADS for non-Wine (instead of always 1), preserving EVSHIM_MAX_PLAYERS overrides and clamping; initializes vjoy_ids to -1 for all slots; Java_com_winlator_winhandler_WinHandler_notifyStateChanged() adds bounds-checking guard with error logging.
WinHandler rumble polling and lifecycle
app/src/main/java/com/winlator/winhandler/WinHandler.java
start() now calls refreshControllerMappings() before enabling running threads; startRumblePoller() spawns one thread per slot that watches native rumble sequence changes and reads low/high values from each slot's shared buffer; stop() tears down rumble and joins all per-slot rumble poller threads.
WinHandler slot-aware input event routing
app/src/main/java/com/winlator/winhandler/WinHandler.java
onKeyEvent() and onGenericMotionEvent() resolve the input device's player slot via ControllerManager, refresh controller mappings when device/controller IDs mismatch, update state for the matching slot/controller, write slot-specific shared-memory state, and broadcast gamepad state changes.
XServerScreen hotplug/device handling and listener
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
External-device scanning auto-assigns connected controllers via ControllerManager and refreshes WinHandler hotplug mappings; gamepad device evaluation notifies ControllerManager of device connection; InputManager.InputDeviceListener forwards connection/disconnection events to ControllerManager before device rescanning.
XServerScreen gamepad input dispatch with slot resolution
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
Gamepad key and motion event handlers resolve the device ID to a player slot via ControllerManager.getSlotForDevice(); valid slots route directly to WinHandler with debug logging; invalid slots fall back through physicalControllerHandler and inputControlsView before passing to WinHandler.
WinHandler vibration and shared-memory state writing
app/src/main/java/com/winlator/winhandler/WinHandler.java
Slot-aware vibration start returns boolean and exits early when amplitude is negligible; introduces stopVibration(slot) and stopDeviceVibration(deviceId) for per-slot shutdown; sendMemoryFileState() delegates to slot-accepting overload; sendVirtualGamepadState(GamepadState, int slot) writes axes/triggers/buttons/hat into slot's mapped buffer; single-parameter overload delegates to slot 0.
XServerScreen controller debug UI overlay and preferences
app/src/main/java/app/gamenative/PrefManager.kt, app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt, app/src/main/java/app/gamenative/ui/component/dialog/ControllerTab.kt
Adds PrefManager.showControllerDebugMenu boolean preference; introduces controllerSlotStatusVersion state and UI data models for per-slot status display; renders controller debug overlay within Quick Menu when enabled; ControllerSlotStatusOverlay composable builds slot/device status snapshots from ControllerManager; updates Compose imports for Material3 support; ControllerTab adds toggle UI with state persistence.
Localization strings for controller debug menu
app/src/main/res/values*/strings.xml
Adds new UI string resources for the controller debug menu label and subtitle across all 15 supported locales (Danish, German, Spanish, French, Italian, Japanese, Korean, Polish, Portuguese, Romanian, Russian, Ukrainian, Simplified Chinese, Traditional Chinese, and English), each describing the live controller slot panel (P1–P4) shown in the in-game quick menu.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • utkarshdalal/GameNative#1471: evshim native JNI/shared-memory logic adjustments to player initialization and bounds validation that this PR extends.
  • utkarshdalal/GameNative#1261: Device ID to slot routing and slot-indexed rumble/buffer handling that aligns with this PR's multi-player refactor.
  • utkarshdalal/GameNative#1047: Multi-controller slot-based routing and WinHandler shared-memory implementation that overlaps with this PR's core infrastructure.

Suggested reviewers

  • utkarshdalal

Poem

🐰 Four gamepads dance in slot-aligned grace,
Rumble threads poll at their proper place,
Shared memory buffers and vJoy tracks,
Native detach/attach brings them back—
Multi-player dreams now fully embrace!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.28% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: Multi controller support' clearly and concisely summarizes the main objective of the changeset, which is to add support for multiple controllers (up to 4 players) across the codebase.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description follows the required template and includes all major sections with sufficient context, recording, type, and checklist entries.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

Comment thread app/src/main/cpp/evshim/evshim.c

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/cpp/evshim/evshim.c (1)

343-347: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clamp EVSHIM_MAX_PLAYERS to a valid lower bound and parse it safely.

At Line 345, atoi can turn malformed input into 0, and the current logic never clamps players back to >= 1. That can skip setup_shm entirely, causing downstream notifyStateChanged(slot) calls to fail and drop controller updates.

Suggested fix
-    int players = g_is_wine ? 1 : MAX_GAMEPADS;
-    const char *ep = getenv("EVSHIM_MAX_PLAYERS");
-    if (ep) players = atoi(ep);
-    if (players > MAX_GAMEPADS) players = MAX_GAMEPADS;
+    int players = g_is_wine ? 1 : MAX_GAMEPADS;
+    const char *ep = getenv("EVSHIM_MAX_PLAYERS");
+    if (ep && *ep) {
+        char *end = NULL;
+        long parsed = strtol(ep, &end, 10);
+        if (end != ep && *end == '\0') {
+            players = (int)parsed;
+        }
+    }
+    if (players < 1) players = 1;
+    if (players > MAX_GAMEPADS) players = MAX_GAMEPADS;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/cpp/evshim/evshim.c` around lines 343 - 347, The code reads
EVSHIM_MAX_PLAYERS into players using atoi which can return 0 for malformed
input and currently allows players to be <1; change the parsing to use a safe
parser (e.g. strtol) and clamp the result to the valid range [1, MAX_GAMEPADS]
so players is never less than 1 (respect g_is_wine which sets default to 1),
then use that clamped players value for setup_shm and subsequent
notifyStateChanged(slot) calls; update references in this block
(getenv("EVSHIM_MAX_PLAYERS"), players, MAX_GAMEPADS, g_is_wine, setup_shm,
notifyStateChanged) accordingly.
🧹 Nitpick comments (2)
app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java (1)

189-192: ⚡ Quick win

Use WinHandler.MAX_PLAYERS instead of a local duplicate.

This local constant duplicates WinHandler.MAX_PLAYERS (also set to 4). If the constant in WinHandler changes, this file won't automatically stay in sync, risking a mismatch between the memory files created here and the buffers/slots managed by WinHandler.

♻️ Suggested fix

Add the import:

+import com.winlator.winhandler.WinHandler;

Then replace the local constant with the reference:

-        final int MAX_PLAYERS = 4;
+        final int MAX_PLAYERS = WinHandler.MAX_PLAYERS;

Or inline the reference at each usage site.

Also applies to: 234-234

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java`
around lines 189 - 192, Replace the local MAX_PLAYERS constant in
BionicProgramLauncherComponent with the canonical WinHandler.MAX_PLAYERS: remove
the local declaration and use WinHandler.MAX_PLAYERS (or import WinHandler and
reference MAX_PLAYERS directly) wherever MAX_PLAYERS is used (e.g., the for-loop
that queries ControllerManager and the other usage around line ~234), ensuring
you no longer have a duplicate hard-coded value.
app/src/main/java/com/winlator/winhandler/WinHandler.java (1)

643-693: 💤 Low value

Consider making running volatile for reliable cross-thread visibility.

The rumble poller threads check running (line 651) in a tight loop. Since running (declared at line 72) is not volatile, the JIT may cache the value in a register and the threads might not see the false written by stop(). The rumbleTeardown() call mitigates this by unblocking waitForRumble(), but adding volatile would guarantee correct visibility.

Also, silently swallowing all exceptions (line 687) could mask unexpected errors during development. Consider logging at debug level.

♻️ Suggested improvements

At line 72, make the field volatile:

-    private boolean running;
+    private volatile boolean running;

At line 687, log the exception:

-                    } catch (Exception ignored) {
+                    } catch (Exception e) {
+                        Timber.d(e, "rumble-poller-%d exception", sl);
                     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/winlator/winhandler/WinHandler.java` around lines 643 -
693, Make the shared boolean "running" used by the rumble poller threads a
volatile field (the declaration of running) so changes from stop() are reliably
visible to threads running the loop in the rumble poller lambda (which calls
waitForRumble(...) and is unblocked by rumbleTeardown()). Also replace the empty
catch in the thread body (the exception swallowed in the try/catch inside the
rumble-poller lambda) with a debug-level log of the exception so unexpected
errors in startVibration/stopVibration/getGamepadBuffer/waitForRumble are
surfaced during development.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt`:
- Around line 1348-1360: The code is currently calling
winHandler.setCurrentController(it.event.device.id) before checking the slot
assignment, which can overwrite WinHandler.currentController for controllers
that already have a valid slot; only set the singleton currentController when no
slot is assigned (playerSlot < 0) and you are about to fall back to legacy
handling. Concretely: remove or move the
winHandler.setCurrentController(it.event.device.id) that runs unconditionally in
XServerScreen around the onKeyEvent flow, and instead call setCurrentController
just inside the "else" branch where playerSlot < 0 (i.e., before calling
winHandler.onKeyEvent when physicalControllerHandler and
PluviaApp.inputControlsView did not handle the event); apply the same change to
the onGenericMotionEvent handling (the same unconditional setCurrentController
there) so slot-aware paths (ControllerManager.getInstance().getSlotForDevice,
winHandler.onKeyEvent, winHandler.onGenericMotionEvent) run without mutating
currentController.

---

Outside diff comments:
In `@app/src/main/cpp/evshim/evshim.c`:
- Around line 343-347: The code reads EVSHIM_MAX_PLAYERS into players using atoi
which can return 0 for malformed input and currently allows players to be <1;
change the parsing to use a safe parser (e.g. strtol) and clamp the result to
the valid range [1, MAX_GAMEPADS] so players is never less than 1 (respect
g_is_wine which sets default to 1), then use that clamped players value for
setup_shm and subsequent notifyStateChanged(slot) calls; update references in
this block (getenv("EVSHIM_MAX_PLAYERS"), players, MAX_GAMEPADS, g_is_wine,
setup_shm, notifyStateChanged) accordingly.

---

Nitpick comments:
In `@app/src/main/java/com/winlator/winhandler/WinHandler.java`:
- Around line 643-693: Make the shared boolean "running" used by the rumble
poller threads a volatile field (the declaration of running) so changes from
stop() are reliably visible to threads running the loop in the rumble poller
lambda (which calls waitForRumble(...) and is unblocked by rumbleTeardown()).
Also replace the empty catch in the thread body (the exception swallowed in the
try/catch inside the rumble-poller lambda) with a debug-level log of the
exception so unexpected errors in
startVibration/stopVibration/getGamepadBuffer/waitForRumble are surfaced during
development.

In
`@app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java`:
- Around line 189-192: Replace the local MAX_PLAYERS constant in
BionicProgramLauncherComponent with the canonical WinHandler.MAX_PLAYERS: remove
the local declaration and use WinHandler.MAX_PLAYERS (or import WinHandler and
reference MAX_PLAYERS directly) wherever MAX_PLAYERS is used (e.g., the for-loop
that queries ControllerManager and the other usage around line ~234), ensuring
you no longer have a duplicate hard-coded value.
🪄 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: b9f22fbc-09b9-41a4-8d8f-30dd54d6107f

📥 Commits

Reviewing files that changed from the base of the PR and between bd2c180 and 76ea524.

⛔ Files ignored due to path filters (1)
  • app/src/main/jniLibs/arm64-v8a/libevshim.so is excluded by !**/*.so
📒 Files selected for processing (4)
  • app/src/main/cpp/evshim/evshim.c
  • app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
  • app/src/main/java/com/winlator/winhandler/WinHandler.java
  • app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java

Comment thread app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt Outdated

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

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.

3 issues found across 5 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/com/winlator/winhandler/WinHandler.java">

<violation number="1" location="app/src/main/java/com/winlator/winhandler/WinHandler.java:391">
P2: Newly added extra gamepad RandomAccessFile mappings are not closed on stop, creating a multi-handle resource leak risk.</violation>

<violation number="2" location="app/src/main/java/com/winlator/winhandler/WinHandler.java:867">
P2: `sendGamepadState()` is still player-1-only but is called from multi-controller event paths</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread app/src/main/java/com/winlator/winhandler/WinHandler.java
Comment thread app/src/main/java/com/winlator/winhandler/WinHandler.java
Comment thread app/src/main/java/com/winlator/winhandler/WinHandler.java
@AndreVto

Copy link
Copy Markdown
Contributor

hotplugging needs to be added, right now it's going to break some games just like the previous multi controller implementation because it has 4 ghost controllers all the time

@phobos665

Copy link
Copy Markdown
Contributor Author

hotplugging needs to be added, right now it's going to break some games just like the previous multi controller implementation because it has 4 ghost controllers all the time

Yeah good call, I'll add that in today. Will need to pick your brains as the thread says that some people's arcade sticks aren't separating into different controllers correctly

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/src/main/java/com/winlator/inputcontrols/ControllerManager.java (1)

194-196: 💤 Low value

Consider returning an unmodifiable view.

Returning the internal detectedDevices list directly allows callers to modify it, potentially corrupting internal state.

♻️ Suggested fix
 public List<InputDevice> getDetectedDevices() {
-    return detectedDevices;
+    return java.util.Collections.unmodifiableList(detectedDevices);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/winlator/inputcontrols/ControllerManager.java` around
lines 194 - 196, The getDetectedDevices() method returns the internal
detectedDevices list directly, allowing callers to modify it and corrupt
internal state. Wrap the return statement with Collections.unmodifiableList() to
return an unmodifiable view of the list instead, preventing external callers
from adding, removing, or modifying elements while still allowing read access.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/main/java/com/winlator/inputcontrols/ControllerManager.java`:
- Around line 365-376: In the onDeviceDisconnected method, after removing the
slot assignment with slotAssignments.remove(slot), add a statement to set
enabledSlots[slot] = false to properly disable the slot and prevent it from
appearing as a ghost controller to games. This mirrors the corresponding logic
in onDeviceConnected which correctly sets enabledSlots[slot] = true when a
device connects.

---

Nitpick comments:
In `@app/src/main/java/com/winlator/inputcontrols/ControllerManager.java`:
- Around line 194-196: The getDetectedDevices() method returns the internal
detectedDevices list directly, allowing callers to modify it and corrupt
internal state. Wrap the return statement with Collections.unmodifiableList() to
return an unmodifiable view of the list instead, preventing external callers
from adding, removing, or modifying elements while still allowing read access.
🪄 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: 3b0c05ac-049b-467d-931f-1b76cdb983ba

📥 Commits

Reviewing files that changed from the base of the PR and between 76ea524 and b8701b8.

📒 Files selected for processing (4)
  • app/src/main/java/app/gamenative/MainActivity.kt
  • app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
  • app/src/main/java/com/winlator/inputcontrols/ControllerManager.java
  • app/src/main/java/com/winlator/winhandler/WinHandler.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/java/com/winlator/winhandler/WinHandler.java

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

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

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.

3 issues found across 4 files (changes from recent commits).

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/com/winlator/inputcontrols/ControllerManager.java">

<violation number="1" location="app/src/main/java/com/winlator/inputcontrols/ControllerManager.java:353">
P2: Hotplug auto-assignment unconditionally enables the chosen slot, overriding any previous user preference to disable it.</violation>

<violation number="2" location="app/src/main/java/com/winlator/inputcontrols/ControllerManager.java:370">
P1: Missing `enabledSlots[slot] = false` in `onDeviceDisconnected` — the slot assignment is removed but the enabled flag remains `true`. This causes `isSlotEnabled()` and `getEnabledPlayerCount()` to still report the slot as occupied, leaving a ghost controller visible to games. Compare with `onDeviceConnected` which correctly sets `enabledSlots[slot] = true`.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt:863">
P1: Controller mapping refresh runs on every generic device scan, causing unnecessary native overhead and rumble interruptions</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread app/src/main/java/com/winlator/inputcontrols/ControllerManager.java
Comment thread app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
Comment thread app/src/main/java/com/winlator/inputcontrols/ControllerManager.java Outdated
@AndreVto

Copy link
Copy Markdown
Contributor

@phobos665 mhm, let me know on any questions

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/cpp/evshim/evshim.c (1)

282-323: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t mark the slot connected when attach fails.

attach_vjoy(idx) can return -1, but set_vjoy_connected drops that result and vjoy_updater still sets last_connected = connected. After one attach/open failure, connected remains true, vjoy_handles[idx] stays NULL, and this slot will skip every future state update without retrying.

Suggested retry-safe flow
-static void set_vjoy_connected(int idx, int connected)
+static int set_vjoy_connected(int idx, int connected)
 {
     if (connected) {
         if (!vjoy_handles[idx]) {
-            attach_vjoy(idx);
+            return attach_vjoy(idx) == 0;
         }
+        return 1;
     } else {
         detach_vjoy(idx);
+        return vjoy_handles[idx] == NULL && vjoy_ids[idx] < 0;
     }
 }
...
-                set_vjoy_connected(idx, connected);
-                last_connected = connected;
+                if (set_vjoy_connected(idx, connected)) {
+                    last_connected = connected;
+                }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/cpp/evshim/evshim.c` around lines 282 - 323, The
set_vjoy_connected function does not check the return value of attach_vjoy, so
when attach_vjoy fails (returns -1), vjoy_handles[idx] remains NULL but the
connection state is still marked as successful. This causes vjoy_updater to set
last_connected to the desired state even though attach failed, resulting in the
slot being skipped in future iterations without retry. Modify set_vjoy_connected
to return an int status (success/failure), have it return failure if attach_vjoy
returns -1, and in vjoy_updater only update last_connected = connected after
set_vjoy_connected successfully returns, so failed attach operations don't
prevent future retry attempts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/main/cpp/evshim/evshim.c`:
- Around line 274-278: The vjoy_ids array stores volatile SDL device indexes
that shift when any joystick detaches, causing vjoy_ids[idx] to reference
incorrect devices after the first player detaches. Additionally, the return
value from p_SDL_JoystickDetachVirtual at line 275 is ignored and vjoy_ids[idx]
is unconditionally cleared at line 277 even if detach fails, potentially leaving
orphaned virtual joysticks. Fix this by querying the current device index for
the virtual joystick before calling p_SDL_JoystickDetachVirtual, checking the
return value to confirm successful detach, and only clearing vjoy_ids[idx] after
a successful detach operation. Alternatively, if the SDL library version
supports it, track joysticks by instance ID (SDL_JoystickID) instead of volatile
device indexes to avoid index shifting problems entirely.

---

Outside diff comments:
In `@app/src/main/cpp/evshim/evshim.c`:
- Around line 282-323: The set_vjoy_connected function does not check the return
value of attach_vjoy, so when attach_vjoy fails (returns -1), vjoy_handles[idx]
remains NULL but the connection state is still marked as successful. This causes
vjoy_updater to set last_connected to the desired state even though attach
failed, resulting in the slot being skipped in future iterations without retry.
Modify set_vjoy_connected to return an int status (success/failure), have it
return failure if attach_vjoy returns -1, and in vjoy_updater only update
last_connected = connected after set_vjoy_connected successfully returns, so
failed attach operations don't prevent future retry attempts.
🪄 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: 7bf23ca9-1b4b-43d5-96d5-2f044c8d55ca

📥 Commits

Reviewing files that changed from the base of the PR and between b8701b8 and 33cdca8.

⛔ Files ignored due to path filters (1)
  • app/src/main/jniLibs/arm64-v8a/libevshim.so is excluded by !**/*.so
📒 Files selected for processing (2)
  • app/src/main/cpp/evshim/evshim.c
  • app/src/main/java/com/winlator/winhandler/WinHandler.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/java/com/winlator/winhandler/WinHandler.java

Comment thread app/src/main/cpp/evshim/evshim.c

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

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.

3 issues found across 3 files (changes from recent commits).

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/com/winlator/inputcontrols/ControllerManager.java">

<violation number="1" location="app/src/main/java/com/winlator/inputcontrols/ControllerManager.java:353">
P2: Hotplug auto-assignment unconditionally enables the chosen slot, overriding any previous user preference to disable it.</violation>

<violation number="2" location="app/src/main/java/com/winlator/inputcontrols/ControllerManager.java:370">
P1: Missing `enabledSlots[slot] = false` in `onDeviceDisconnected` — the slot assignment is removed but the enabled flag remains `true`. This causes `isSlotEnabled()` and `getEnabledPlayerCount()` to still report the slot as occupied, leaving a ghost controller visible to games. Compare with `onDeviceConnected` which correctly sets `enabledSlots[slot] = true`.</violation>
</file>

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

<violation number="1" location="app/src/main/cpp/evshim/evshim.c:256">
P1: Using the stored device index (`vjoy_ids[idx]`) for `SDL_JoystickDetachVirtual` is unreliable because SDL2 device indexes are volatile—they shift down when any lower-indexed joystick is removed. If player 0 detaches first, `vjoy_ids[1]` no longer refers to the correct device. Additionally, the return value of `SDL_JoystickDetachVirtual` is ignored, meaning `vjoy_ids[idx]` is cleared unconditionally even if detach fails (leaving an orphaned virtual joystick).

Consider resolving the current device index from the joystick's instance ID before detaching, or tracking joysticks by instance ID (`SDL_JoystickInstanceID`) rather than volatile device indexes. Also check the return value before clearing local state.</violation>

<violation number="2" location="app/src/main/cpp/evshim/evshim.c:323">
P1: Attach failure can permanently strand a controller slot because `last_connected` is updated even when `attach_vjoy` fails, preventing retries.</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:248">
P1: Startup/reconnect can retain stale gamepad state because neutralization is conditional on the persisted `OFF_CONNECTED` value. The mmap files are not explicitly zeroed on creation, so a prior run may leave `OFF_CONNECTED = 1` and stale button/axis bytes. The connect path only calls `writeNeutralGamepadState` when `connected && buffer.getInt(OFF_CONNECTED) == 0`, so if the persisted flag is already 1, stale input state persists until the next real event. Neutralize state unconditionally when a slot becomes connected (or track in-memory transitions) rather than relying on the persisted flag.</violation>

<violation number="2" location="app/src/main/java/com/winlator/winhandler/WinHandler.java:391">
P2: Newly added extra gamepad RandomAccessFile mappings are not closed on stop, creating a multi-handle resource leak risk.</violation>

<violation number="3" location="app/src/main/java/com/winlator/winhandler/WinHandler.java:867">
P2: `sendGamepadState()` is still player-1-only but is called from multi-controller event paths</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread app/src/main/cpp/evshim/evshim.c
Comment thread app/src/main/cpp/evshim/evshim.c
Comment thread app/src/main/java/com/winlator/winhandler/WinHandler.java
@DiegoBM

DiegoBM commented Jun 19, 2026

Copy link
Copy Markdown

Any chance to push this feature forward please? It's really the only thing stopping me from moving from gamehub

@phobos665 phobos665 changed the title feat: Multi controller support (WIP) feat: Multi controller support Jun 21, 2026
Comment thread app/src/main/cpp/evshim/evshim.c

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt (1)

3176-3181: ⚡ Quick win

Remove side effect from snapshot builder function.

autoAssignConnectedDevices() mutates ControllerManager state but is called inside buildControllerStatusSnapshot(), which is invoked from a remember block. This creates a code smell:

  1. Redundant – already invoked in scanForExternalDevices() (line 891) and through hotplug callbacks
  2. Violates remember contract – the lambda should be a pure computation; side effects during recomposition can cause subtle timing issues

Since auto-assignment is already triggered at the appropriate points in the hotplug lifecycle, this call can be safely removed.

Suggested fix
 private fun buildControllerStatusSnapshot(
     controllerManager: ControllerManager,
     container: Container,
     areControlsVisible: Boolean,
 ): ControllerStatusSnapshot {
-    controllerManager.autoAssignConnectedDevices()
     val guestApi = describePreferredInputApi(container)
     val slots = (0 until WinHandler.MAX_PLAYERS).map { slot ->
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt` around
lines 3176 - 3181, Remove the call to
controllerManager.autoAssignConnectedDevices() from inside the
buildControllerStatusSnapshot() function, as this function is invoked from a
remember block which should contain only pure computations without side effects.
The auto-assignment functionality is already being triggered through
scanForExternalDevices() and hotplug callbacks, making this call redundant and
causing timing issues during recomposition.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt`:
- Around line 3176-3181: Remove the call to
controllerManager.autoAssignConnectedDevices() from inside the
buildControllerStatusSnapshot() function, as this function is invoked from a
remember block which should contain only pure computations without side effects.
The auto-assignment functionality is already being triggered through
scanForExternalDevices() and hotplug callbacks, making this call redundant and
causing timing issues during recomposition.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 587314b0-bcce-4c4a-b285-1912bd57f17c

📥 Commits

Reviewing files that changed from the base of the PR and between 33cdca8 and 1b64329.

📒 Files selected for processing (3)
  • app/src/main/java/app/gamenative/PrefManager.kt
  • app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
  • app/src/main/java/com/winlator/inputcontrols/ControllerManager.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/java/com/winlator/inputcontrols/ControllerManager.java

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

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.

1 issue found across 3 files (changes from recent commits).

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/com/winlator/inputcontrols/ControllerManager.java">

<violation number="1" location="app/src/main/java/com/winlator/inputcontrols/ControllerManager.java:353">
P2: Hotplug auto-assignment unconditionally enables the chosen slot, overriding any previous user preference to disable it.</violation>

<violation number="2" location="app/src/main/java/com/winlator/inputcontrols/ControllerManager.java:370">
P1: Missing `enabledSlots[slot] = false` in `onDeviceDisconnected` — the slot assignment is removed but the enabled flag remains `true`. This causes `isSlotEnabled()` and `getEnabledPlayerCount()` to still report the slot as occupied, leaving a ghost controller visible to games. Compare with `onDeviceConnected` which correctly sets `enabledSlots[slot] = true`.</violation>
</file>

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

<violation number="1" location="app/src/main/cpp/evshim/evshim.c:256">
P1: Using the stored device index (`vjoy_ids[idx]`) for `SDL_JoystickDetachVirtual` is unreliable because SDL2 device indexes are volatile—they shift down when any lower-indexed joystick is removed. If player 0 detaches first, `vjoy_ids[1]` no longer refers to the correct device. Additionally, the return value of `SDL_JoystickDetachVirtual` is ignored, meaning `vjoy_ids[idx]` is cleared unconditionally even if detach fails (leaving an orphaned virtual joystick).

Consider resolving the current device index from the joystick's instance ID before detaching, or tracking joysticks by instance ID (`SDL_JoystickInstanceID`) rather than volatile device indexes. Also check the return value before clearing local state.</violation>

<violation number="2" location="app/src/main/cpp/evshim/evshim.c:323">
P1: Attach failure can permanently strand a controller slot because `last_connected` is updated even when `attach_vjoy` fails, preventing retries.</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:248">
P1: Startup/reconnect can retain stale gamepad state because neutralization is conditional on the persisted `OFF_CONNECTED` value. The mmap files are not explicitly zeroed on creation, so a prior run may leave `OFF_CONNECTED = 1` and stale button/axis bytes. The connect path only calls `writeNeutralGamepadState` when `connected && buffer.getInt(OFF_CONNECTED) == 0`, so if the persisted flag is already 1, stale input state persists until the next real event. Neutralize state unconditionally when a slot becomes connected (or track in-memory transitions) rather than relying on the persisted flag.</violation>

<violation number="2" location="app/src/main/java/com/winlator/winhandler/WinHandler.java:391">
P2: Newly added extra gamepad RandomAccessFile mappings are not closed on stop, creating a multi-handle resource leak risk.</violation>

<violation number="3" location="app/src/main/java/com/winlator/winhandler/WinHandler.java:867">
P2: `sendGamepadState()` is still player-1-only but is called from multi-controller event paths</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
@phobos665

Copy link
Copy Markdown
Contributor Author

Any chance to push this feature forward please? It's really the only thing stopping me from moving from gamehub

It's under review with the other devs and should have something soonish.

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

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.

1 issue found across 2 files (changes from recent commits).

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/com/winlator/inputcontrols/ControllerManager.java">

<violation number="1" location="app/src/main/java/com/winlator/inputcontrols/ControllerManager.java:353">
P2: Hotplug auto-assignment unconditionally enables the chosen slot, overriding any previous user preference to disable it.</violation>

<violation number="2" location="app/src/main/java/com/winlator/inputcontrols/ControllerManager.java:370">
P1: Missing `enabledSlots[slot] = false` in `onDeviceDisconnected` — the slot assignment is removed but the enabled flag remains `true`. This causes `isSlotEnabled()` and `getEnabledPlayerCount()` to still report the slot as occupied, leaving a ghost controller visible to games. Compare with `onDeviceConnected` which correctly sets `enabledSlots[slot] = true`.</violation>
</file>

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

<violation number="1" location="app/src/main/cpp/evshim/evshim.c:323">
P1: Attach failure can permanently strand a controller slot because `last_connected` is updated even when `attach_vjoy` fails, preventing retries.</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:248">
P1: Startup/reconnect can retain stale gamepad state because neutralization is conditional on the persisted `OFF_CONNECTED` value. The mmap files are not explicitly zeroed on creation, so a prior run may leave `OFF_CONNECTED = 1` and stale button/axis bytes. The connect path only calls `writeNeutralGamepadState` when `connected && buffer.getInt(OFF_CONNECTED) == 0`, so if the persisted flag is already 1, stale input state persists until the next real event. Neutralize state unconditionally when a slot becomes connected (or track in-memory transitions) rather than relying on the persisted flag.</violation>

<violation number="2" location="app/src/main/java/com/winlator/winhandler/WinHandler.java:391">
P2: Newly added extra gamepad RandomAccessFile mappings are not closed on stop, creating a multi-handle resource leak risk.</violation>

<violation number="3" location="app/src/main/java/com/winlator/winhandler/WinHandler.java:867">
P2: `sendGamepadState()` is still player-1-only but is called from multi-controller event paths</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt:2955">
P1: After hiding virtual on-screen controls, player 1 may remain unassigned because `hideInputControls()` refreshes controller mappings without restoring/reassigning a connected physical controller to slot 0.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
… into feat/multi-controller-support-without-ui
…rect slots, as well as only activate slot when connected to avoid issues with single-controller games.

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

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.

1 issue found across 3 files (changes from recent commits).

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/com/winlator/inputcontrols/ControllerManager.java">

<violation number="1" location="app/src/main/java/com/winlator/inputcontrols/ControllerManager.java:353">
P2: Hotplug auto-assignment unconditionally enables the chosen slot, overriding any previous user preference to disable it.</violation>

<violation number="2" location="app/src/main/java/com/winlator/inputcontrols/ControllerManager.java:370">
P1: Missing `enabledSlots[slot] = false` in `onDeviceDisconnected` — the slot assignment is removed but the enabled flag remains `true`. This causes `isSlotEnabled()` and `getEnabledPlayerCount()` to still report the slot as occupied, leaving a ghost controller visible to games. Compare with `onDeviceConnected` which correctly sets `enabledSlots[slot] = true`.</violation>
</file>

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

<violation number="1" location="app/src/main/cpp/evshim/evshim.c:323">
P1: Attach failure can permanently strand a controller slot because `last_connected` is updated even when `attach_vjoy` fails, preventing retries.</violation>

<violation number="2" location="app/src/main/cpp/evshim/evshim.c:335">
P1: State updates are no longer gated by `connected`, so a retained virtual joystick handle can keep receiving stale input after the slot is logically disconnected. This can cause ghost inputs or stuck controller state when the Java side marks a slot disconnected without neutralizing the buffer.</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:248">
P1: Startup/reconnect can retain stale gamepad state because neutralization is conditional on the persisted `OFF_CONNECTED` value. The mmap files are not explicitly zeroed on creation, so a prior run may leave `OFF_CONNECTED = 1` and stale button/axis bytes. The connect path only calls `writeNeutralGamepadState` when `connected && buffer.getInt(OFF_CONNECTED) == 0`, so if the persisted flag is already 1, stale input state persists until the next real event. Neutralize state unconditionally when a slot becomes connected (or track in-memory transitions) rather than relying on the persisted flag.</violation>

<violation number="2" location="app/src/main/java/com/winlator/winhandler/WinHandler.java:391">
P2: Newly added extra gamepad RandomAccessFile mappings are not closed on stop, creating a multi-handle resource leak risk.</violation>

<violation number="3" location="app/src/main/java/com/winlator/winhandler/WinHandler.java:867">
P2: `sendGamepadState()` is still player-1-only but is called from multi-controller event paths</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt:2955">
P1: After hiding virtual on-screen controls, player 1 may remain unassigned because `hideInputControls()` refreshes controller mappings without restoring/reassigning a connected physical controller to slot 0.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread app/src/main/cpp/evshim/evshim.c Outdated

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

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.

1 issue found across 2 files (changes from recent commits).

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/com/winlator/winhandler/WinHandler.java">

<violation number="1" location="app/src/main/java/com/winlator/winhandler/WinHandler.java:248">
P1: Startup/reconnect can retain stale gamepad state because neutralization is conditional on the persisted `OFF_CONNECTED` value. The mmap files are not explicitly zeroed on creation, so a prior run may leave `OFF_CONNECTED = 1` and stale button/axis bytes. The connect path only calls `writeNeutralGamepadState` when `connected && buffer.getInt(OFF_CONNECTED) == 0`, so if the persisted flag is already 1, stale input state persists until the next real event. Neutralize state unconditionally when a slot becomes connected (or track in-memory transitions) rather than relying on the persisted flag.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt:2955">
P1: After hiding virtual on-screen controls, player 1 may remain unassigned because `hideInputControls()` refreshes controller mappings without restoring/reassigning a connected physical controller to slot 0.</violation>
</file>

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

<violation number="1" location="app/src/main/cpp/evshim/evshim.c:341">
P1: Virtual joystick state updates can race with the new close/detach path in multi-controller mode. Each `vjoy_updater` thread reads `vjoy_handles[idx]` and calls `apply_vjoy_state` (which invokes `SDL_JoystickSetVirtualAxis/Button/Hat`) without holding `sdl_vjoy_lock`, while `detach_vjoy` now closes and detaches the joystick under that same lock. SDL documents that callers must use `SDL_LockJoysticks()` when accessing the joystick API from multiple threads, and the internal virtual-joystick set functions assert the joystick lock. This can lead to a use-after-close or crash when a controller disconnects while another slot is updating. Consider holding the same lock while reading the handle and applying state, or wrapping all SDL joystick calls with `SDL_LockJoysticks()`/`SDL_UnlockJoysticks()`.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment on lines +341 to +344
if (!connected || !js) {
continue;
}

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.

P1: Virtual joystick state updates can race with the new close/detach path in multi-controller mode. Each vjoy_updater thread reads vjoy_handles[idx] and calls apply_vjoy_state (which invokes SDL_JoystickSetVirtualAxis/Button/Hat) without holding sdl_vjoy_lock, while detach_vjoy now closes and detaches the joystick under that same lock. SDL documents that callers must use SDL_LockJoysticks() when accessing the joystick API from multiple threads, and the internal virtual-joystick set functions assert the joystick lock. This can lead to a use-after-close or crash when a controller disconnects while another slot is updating. Consider holding the same lock while reading the handle and applying state, or wrapping all SDL joystick calls with SDL_LockJoysticks()/SDL_UnlockJoysticks().

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

<comment>Virtual joystick state updates can race with the new close/detach path in multi-controller mode. Each `vjoy_updater` thread reads `vjoy_handles[idx]` and calls `apply_vjoy_state` (which invokes `SDL_JoystickSetVirtualAxis/Button/Hat`) without holding `sdl_vjoy_lock`, while `detach_vjoy` now closes and detaches the joystick under that same lock. SDL documents that callers must use `SDL_LockJoysticks()` when accessing the joystick API from multiple threads, and the internal virtual-joystick set functions assert the joystick lock. This can lead to a use-after-close or crash when a controller disconnects while another slot is updating. Consider holding the same lock while reading the handle and applying state, or wrapping all SDL joystick calls with `SDL_LockJoysticks()`/`SDL_UnlockJoysticks()`.</comment>

<file context>
@@ -332,7 +338,7 @@ static void *vjoy_updater(void *arg)
             }
             SDL_Joystick *js = vjoy_handles[idx];
-            if (!js) {
+            if (!connected || !js) {
                 continue;
             }
</file context>

p_SDL_JoystickSetVirtualAxis(js, 5, snap.state.rt);
for (int i = 0; i < 15; i++) {
p_SDL_JoystickSetVirtualButton(js, i, snap.state.btn[i]);
int connected = atomic_load_explicit(&s->connected, memory_order_acquire) != 0;

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.

we can read from the snapshot we just got right, that should be fine?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah should be. I'll pick this one up with you for more details on how to improve it since this bit was a struggle

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.

oh, this one is simple but it didn't show as I expected
int connected can read from snap.connected directly since try_read_state was successful

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah I felt this was just a bit easier to keep track this way


static void detach_vjoy(int idx)
{
pthread_mutex_lock(&sdl_vjoy_lock);

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.

are these mutexes actually needed?
thread for controller 1 will always read position for player 1 right? and the same for the others

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

So I was having some issues regarding keeping slots, so popped this in to avoid this.

I can always remove and observe the behaviour

@AndreVto AndreVto Jul 1, 2026

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.

from what I understood there is 1 thread for each joy right? even when no controllers are actually attached? each thread keeps 1 controller id (0, 1, 2, 3) and when it accesses vjoy_handles it reads using the idx it receives when the thread was created

there is no collision of slots in evshim side, when java side notifies a state change it lets controller index x thread update, but there is only one for each controller. each thread updates 1 slot of memory of the array, there is no need for the mutex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's a fair point. I'll take a look through this over the next couple of days. I'll hit you up a bit more since I'm not too great with the evshim work

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah after reading up on this, we don't need the mutex. We can just let the SDL logic handle this. I was being too pernickity.

Will remove and update.

@AndreVto

AndreVto commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

controllers have preferred slots? can you go over on the slot logic?

@phobos665

phobos665 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

controllers have preferred slots? can you go over on the slot logic?

So controllers should generally keep the slot ready to receive it in the case of a momentary disconnect and reconnect, unless another controller connects. Otherwise it should grab the lowest slot available.

That might not really be desired behaviour though, but that's the intent.

@AndreVto

AndreVto commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

So controllers should generally keep the slot ready to receive it in the case of a momentary disconnect and reconnect, unless another controller connects. Otherwise it should grab the lowest slot available.

That might not really be desired behaviour though, but that's the intent.

is disconnect/reconnect state change also delayed for evshim? otherwise it'll be possibly out of sync between evshim/SDL and java code

@utkarshdalal

Copy link
Copy Markdown
Owner

@phobos665 there's a question for you above

@phobos665

phobos665 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

TODO:

  • Make any required adjustments to the evshim based on chat with Avalumi
  • Update the debug UI to be properly user-facing and be inside the Quick Menu

@phobos665

Copy link
Copy Markdown
Contributor Author

So controllers should generally keep the slot ready to receive it in the case of a momentary disconnect and reconnect, unless another controller connects. Otherwise it should grab the lowest slot available.
That might not really be desired behaviour though, but that's the intent.

is disconnect/reconnect state change also delayed for evshim? otherwise it'll be possibly out of sync between evshim/SDL and java code

It's not no, we send down the connected state down to the evshim and essentially use that to control its state. That way it shouldn't get out of sync. (Might seem a bit blunt, but I feel like it's the easiest and most obvious way of controlling it).

@phobos665

Copy link
Copy Markdown
Contributor Author

So after the discussion, only thing that we should do is optionally remove the mutex. Shouldn't cause issues either way.

@utkarshdalal Happy to merge as-is and we can iterate/improve on this solution as we go if that's fine?

Avalumi is happy with it, just had a couple of clarifying questions and quick sense-checks.

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.

4 participants