Skip to content

fix(sokol-android): initialize gamepad subsystem so connected pads show at startup - #268

Merged
apotema merged 1 commit into
mainfrom
fix/261-android-gamepad-init
Jun 11, 2026
Merged

apotema merged 1 commit into
mainfrom
fix/261-android-gamepad-init

Conversation

@apotema

@apotema apotema commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Problem (confirmed on hardware)

On the sokol-Android gamepad demo running on a real tablet (Samsung SM-T505) with an Xbox Wireless Controller paired, the HUD showed "No gamepad connected" even though the pad was connected before launch. Found via on-device debugging — references labelle-engine#261.

Root cause(s)

Three distinct issues, in order of impact:

  1. JNIEnv from the wrong thread (the real bug). backends/sokol/src/android_gamepad_jni.c's labelle_android_gamepad_init used ANativeActivity.env, which is the JNIEnv of the thread that created the activity (Android main thread). sokol runs init_cb and the frame loop on its own render thread, where that env is invalid → every InputManager/InputDevice reflection call silently failed → enumeration found nothing → empty HUD. Fixed by attaching the current thread to the JavaVM (GetEnv + AttachCurrentThread).

  2. Backend never invoked the JNI enumeration. initAndroidGamepad() registered the forwarded-event callback but never called labelle_android_gamepad_init(), and the generated mobile init() never called initAndroidGamepad() at all. Now wired in both places. The template addition is a comptime no-op on non-Android sokol targets (desktop/wasm/iOS).

  3. getInputDeviceIds() returns every input device (keyboard, touchscreen, power button, virtual keyboard, …), not just controllers. Forwarding them all surfaced system devices as "pads". Now filtered to gamepad/joystick sources at the discovery point (mirrors classifySources in labelle-core).

Demo fix: connected_pads.zig keyed its registry by raw Android device id into a 4-slot array, dropping any id ≥ 4 — the Xbox enumerated as device id 9. Now keyed by device id, and the HUD iterates the registry's real ids instead of a 0..3 slot scan.

Verification

Built the debug APK (NDK 26.3.11579264) and ran on a Samsung SM-T505 with an Xbox Wireless Controller connected.

  • Before: HUD = "No gamepad connected".
  • After: HUD shows Pad 9: Gamepad with the live button/axis grid at startup, without any button press. No crash.

logcat (JNI init, with temporary diagnostics that were removed before commit):

init: AttachCurrentThread rc=0 env=0x...
enumerate_existing: 7 input device id(s)
emit_device_added: id=9 sources=0x1000511 name='Xbox Wireless Controller'

(the 6 non-gamepad devices are filtered out)

zig build test green; generated mobile build.zig AstGen-compiles.

Known follow-up (out of scope)

The HUD label reads Pad 9: Gamepad [unknown] rather than the device name Xbox Wireless Controller. The name/type-hint propagation through the engine gamepad_connected event is a separate concern; detection (the #261 bug) is fixed.

Refs labelle-engine#261

…ow at startup

The sokol-Android gamepad demo showed "No gamepad connected" even with an
Xbox controller already paired, because the Android gamepad JNI subsystem
was never usable at runtime. Three issues, found via on-device debugging on
a real tablet (labelle-engine#261):

1. JNIEnv from the wrong thread (the real bug). The C JNI glue used
   `ANativeActivity.env`, which belongs to the thread that created the
   activity (Android main thread). sokol runs init_cb / the frame loop on its
   own render thread, where that env is invalid, so every InputManager /
   InputDevice reflection call silently failed and the device enumeration
   found nothing. Attach the current thread to the JavaVM
   (GetEnv + AttachCurrentThread) to obtain a valid JNIEnv.

2. Backend never invoked the JNI enumeration. `initAndroidGamepad()`
   registered the forwarded-event callback but never called
   `labelle_android_gamepad_init()`, and the generated mobile `init()` never
   called `initAndroidGamepad()` at all. Wire both up (the engine's
   gamepad_source also calls the JNI init; the call is idempotent). The
   template addition is a comptime no-op on non-Android sokol targets.

3. getInputDeviceIds() returns every input device (keyboard, touchscreen,
   power button, …), not just controllers. Filter the enumeration to
   gamepad/joystick sources so the HUD lists the pad, not system devices.

Demo: the HUD keyed its connected-pad registry by raw Android device id into
a 4-slot array, dropping any id >= 4 (the Xbox enumerated as id 9). Key by
device id and iterate the registry's real ids instead of a 0..3 slot scan.

Verified on a Samsung SM-T505 tablet with an Xbox Wireless Controller: the
HUD shows the pad at startup (no button press) instead of the empty state.

Refs labelle-engine#261
@cursor

cursor Bot commented Jun 11, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes Android JNI threading and device discovery at startup; scope is gamepad input only, with a comptime-gated mobile template call and localized demo registry fixes.

Overview
Fixes empty gamepad HUD at launch on sokol-Android (labelle-engine#261) by making JNI discovery run on a valid thread, actually invoking it at startup, and filtering non-controller devices.

JNI (android_gamepad_jni.c): labelle_android_gamepad_init no longer uses ANativeActivity.env (main thread only). It attaches the current render thread via GetEnv / AttachCurrentThread so InputManager enumeration works. emit_device_added now skips devices whose getSources() is not gamepad/joystick (aligned with labelle-core classifySources), so keyboards/touchscreens are not reported as pads.

Backend wiring: initAndroidGamepad() registers the forwarded-event callback and calls labelle_android_gamepad_init. The mobile template init() also calls initAndroidGamepad() (comptime no-op off Android).

Demo: connected_pads.zig stores pads in compact slots keyed by platform device id (e.g. 9), adds knownIds, and gamepad_hud.zig iterates those ids instead of assuming dense slots 0..3.

Reviewed by Cursor Bugbot for commit 659341f. Bugbot is set up for automated code reviews on this repo. Configure here.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request addresses Android gamepad detection issues by filtering out non-gamepad input devices, attaching the render thread to the JVM to obtain a valid JNIEnv, and refactoring gamepad tracking to support sparse platform device IDs instead of dense 0..3 slots. The review feedback highlights critical improvements, including adding a one-shot guard to prevent double-initialization of the gamepad subsystem, implementing a thread-local destructor to safely detach the render thread from the JVM on exit, and refactoring the missed nameFor function to use findById to prevent incorrect fallback names for sparse device IDs.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines 94 to 105
pub fn initAndroidGamepad() void {
if (comptime agp.is_android) {
// Forwarded-event sink first, then the JNI enumeration: the latter
// walks the InputManager device ids and emits state-added for pads
// already connected at launch, so the HUD shows them without a button
// press. `androidGetNativeActivity()` wraps sokol's
// `sapp_android_get_native_activity()`; it is null before sokol has a
// running activity, and the C side no-ops on a null pointer.
android_gp.sapp_android_register_gamepad_callback(&androidGamepadCallback);
android_gp.labelle_android_gamepad_init(sapp.androidGetNativeActivity());
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

Currently, initAndroidGamepad() is called twice: once in init() (via mobile.txt) and once in newFrame() on the first frame because android_gp_registered is not set to true inside initAndroidGamepad(). This double-initialization causes duplicate JNI initializations, leaks JNI global references, and triggers duplicate device connection events. Adding a one-shot guard inside initAndroidGamepad() completely prevents this.

pub fn initAndroidGamepad() void {
    if (comptime agp.is_android) {
        if (android_gp_registered) return;
        android_gp_registered = true;
        // Forwarded-event sink first, then the JNI enumeration: the latter
        // walks the InputManager device ids and emits state-added for pads
        // already connected at launch, so the HUD shows them without a button
        // press. `androidGetNativeActivity()` wraps sokol's
        // `sapp_android_get_native_activity()`; it is null before sokol has a
        // running activity, and the C side no-ops on a null pointer.
        android_gp.sapp_android_register_gamepad_callback(&androidGamepadCallback);
        android_gp.labelle_android_gamepad_init(sapp.androidGetNativeActivity());
    }
}

Comment on lines +74 to +77
static int is_gamepad_sources(jint sources) {
return ((sources & LBL_SOURCE_GAMEPAD) == LBL_SOURCE_GAMEPAD) ||
((sources & LBL_SOURCE_JOYSTICK) == LBL_SOURCE_JOYSTICK);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

To prevent JNI thread leaks and potential JVM crashes when the sokol render thread exits (e.g., during activity recreation or screen rotation), we should automatically detach the thread from the Java VM. We can achieve this robustly by creating a thread-local key with a destructor that calls DetachCurrentThread when the thread exits.

static int is_gamepad_sources(jint sources) {
    return ((sources & LBL_SOURCE_GAMEPAD) == LBL_SOURCE_GAMEPAD) ||
           ((sources & LBL_SOURCE_JOYSTICK) == LBL_SOURCE_JOYSTICK);
}

#include <pthread.h>

static pthread_key_t g_detach_key;
static pthread_once_t g_detach_key_once = PTHREAD_ONCE_INIT;

static void detach_thread_destructor(void *arg) {
    (void)arg;
    if (g_vm) {
        (*g_vm)->DetachCurrentThread(g_vm);
    }
}

static void make_detach_key(void) {
    pthread_key_create(&g_detach_key, detach_thread_destructor);
}

Comment on lines +310 to +316
JNIEnv *env = NULL;
if (g_vm != NULL) {
jint rc = (*g_vm)->GetEnv(g_vm, (void **)&env, JNI_VERSION_1_6);
if (rc == JNI_EDETACHED) {
(*g_vm)->AttachCurrentThread(g_vm, &env, NULL);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Register the thread-local destructor when attaching the thread to the Java VM so that it is automatically detached upon thread exit.

    JNIEnv *env = NULL;
    if (g_vm != NULL) {
        jint rc = (*g_vm)->GetEnv(g_vm, (void **)&env, JNI_VERSION_1_6);
        if (rc == JNI_EDETACHED) {
            if ((*g_vm)->AttachCurrentThread(g_vm, &env, NULL) == JNI_OK) {
                pthread_once(&g_detach_key_once, make_detach_key);
                pthread_setspecific(g_detach_key, (void *)1);
            }
        }
    }

Comment on lines 80 to +83
pub fn typeHintFor(id: u32) [:0]const u8 {
if (id >= MAX_GAMEPADS) return "unknown";
return entries[id].type_hint;
if (findById(id)) |e| return e.type_hint;
return "unknown";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

While typeHintFor was correctly updated to use findById(id), nameFor (defined just above on lines 68-77) was missed during the refactoring. It still checks if (id >= MAX_GAMEPADS) return "Gamepad"; and indexes entries directly with the raw platform ID.

Because Android platform device IDs are sparse and routinely exceed MAX_GAMEPADS (e.g., the Xbox controller with ID 9), nameFor will always return "Gamepad" instead of the actual device name for these controllers.

Please refactor nameFor to use findById(id) as well:

pub fn nameFor(id: u32) [:0]const u8 {
    const e = findById(id) orelse return "Gamepad";
    if (e.name_len == 0) return "Gamepad";
    if (e.name_len < NAME_CAP) e.name[e.name_len] = 0;
    return e.name[0..e.name_len :0];
}

@apotema
apotema merged commit 8535bf2 into main Jun 11, 2026
4 checks passed

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 659341f. Configure here.

pub fn typeHintFor(id: u32) [:0]const u8 {
if (id >= MAX_GAMEPADS) return "unknown";
return entries[id].type_hint;
if (findById(id)) |e| return e.type_hint;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nameFor ignores slot lookup

Medium Severity

nameFor still treats the platform device id as a dense index into entries (and returns early when id >= MAX_GAMEPADS), while record, forget, and typeHintFor now store and resolve pads by platform id via findById. For Android ids such as 9, stored names from gamepad_connected are never returned, so the HUD keeps the generic Gamepad label even when the registry has the real name.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 659341f. Configure here.

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