diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0f9f745..1dacf76 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,8 @@ tools:ignore="ScopedStorage" /> + + + diff --git a/app/src/main/java/com/lazy/emulate/MainActivity.kt b/app/src/main/java/com/lazy/emulate/MainActivity.kt index 6af4a93..0cc392c 100644 --- a/app/src/main/java/com/lazy/emulate/MainActivity.kt +++ b/app/src/main/java/com/lazy/emulate/MainActivity.kt @@ -1,6 +1,11 @@ package com.lazy.emulate +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager import android.net.Uri import android.os.Build import android.os.Bundle @@ -26,6 +31,15 @@ class MainActivity : ComponentActivity() { private lateinit var controllerManager: ControllerManager private lateinit var gameRepository: GameRepository + + private val usbAttachReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != UsbManager.ACTION_USB_DEVICE_ATTACHED) return + @Suppress("DEPRECATION") + val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + if (device != null) controllerManager.onUsbDeviceAttached(device) + } + } lateinit var preferencesManager: PreferencesManager private set lateinit var buttonMappingManager: ButtonMappingManager @@ -44,6 +58,9 @@ class MainActivity : ComponentActivity() { gameRepository = GameRepository(this) EmulationEngine.buttonMappingManager = buttonMappingManager + // Note: we deliberately do NOT call handleUsbAttachIntent(intent) here. + // start() hasn't registered the permission receiver yet, and onResume() will + // do a usbManager.deviceList sweep that picks up any device that's already attached. setContent { EmulateTheme { val windowSizeClass = calculateWindowSizeClass(this) @@ -64,6 +81,16 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() controllerManager.start() + // Silently receive USB attach events while the app is foregrounded — no dialogs. + val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(usbAttachReceiver, filter, RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + registerReceiver(usbAttachReceiver, filter) + } + // Also sweep for any matching device that's already connected when we resume. + controllerManager.tryClaimAlreadyConnectedUsb() if (Environment.isExternalStorageManager()) { gameRepository.scanGameFolders() } @@ -71,9 +98,29 @@ class MainActivity : ComponentActivity() { override fun onPause() { super.onPause() + try { unregisterReceiver(usbAttachReceiver) } catch (_: Throwable) {} controllerManager.stop() } + override fun onDestroy() { + super.onDestroy() + controllerManager.releaseClaim() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleUsbAttachIntent(intent) + } + + private fun handleUsbAttachIntent(intent: Intent?) { + if (intent?.action != UsbManager.ACTION_USB_DEVICE_ATTACHED) return + @Suppress("DEPRECATION") + val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + if (device != null) { + controllerManager.onUsbDeviceAttached(device) + } + } + private fun requestAllFilesPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) { val intent = Intent( @@ -84,18 +131,16 @@ class MainActivity : ComponentActivity() { } } - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - if (event != null && controllerManager.handleKeyEvent(event)) return true - return super.onKeyDown(keyCode, event) + // Compose's root view installs its own key-event handler and will consume events + // before Activity.onKeyDown/onKeyUp run. Intercept at dispatchKeyEvent instead so + // gamepad input always reaches ControllerManager. + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (controllerManager.handleKeyEvent(event)) return true + return super.dispatchKeyEvent(event) } - override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { - if (event != null && controllerManager.handleKeyEvent(event)) return true - return super.onKeyUp(keyCode, event) - } - - override fun onGenericMotionEvent(event: MotionEvent?): Boolean { - if (event != null && controllerManager.handleMotionEvent(event)) return true - return super.onGenericMotionEvent(event) + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (controllerManager.handleMotionEvent(event)) return true + return super.dispatchGenericMotionEvent(event) } } diff --git a/app/src/main/java/com/lazy/emulate/data/CoverArtManager.kt b/app/src/main/java/com/lazy/emulate/data/CoverArtManager.kt index b68f12b..12983c3 100644 --- a/app/src/main/java/com/lazy/emulate/data/CoverArtManager.kt +++ b/app/src/main/java/com/lazy/emulate/data/CoverArtManager.kt @@ -31,13 +31,25 @@ class CoverArtManager(private val context: Context) { val dest = cacheFile(gameTitle, consoleType) - // Try the title as-is, with expanded regions, then fully cleaned + // Build the list of candidate filenames to try, in priority order: + // 1. Title as-is + // 2. Title with GoodTools-style region tags expanded into LibRetro forms + // (e.g. "(U)" -> "(USA)", "(USA, Europe)", "(World)") + // 3. Fully cleaned title (no parens at all) — rare hit + // 4. Cleaned title with each common LibRetro region tag appended — this catches + // cases like "Super Mario Bros. (U)" where LibRetro stores "(World)" but our + // region table didn't know that this specific game used the World tag. val namesToTry = buildList { add(gameTitle) val expanded = expandRegions(gameTitle) expanded.filter { it != gameTitle }.forEach { add(it) } val cleaned = cleanTitle(gameTitle) if (cleaned != gameTitle && cleaned !in expanded) add(cleaned) + // Append fallback region tags to the bare cleaned title + for (region in COMMON_LIBRETRO_REGIONS) { + val candidate = "$cleaned $region" + if (candidate !in this) add(candidate) + } } for (name in namesToTry) { @@ -45,10 +57,12 @@ class CoverArtManager(private val context: Context) { .replace("+", "%20") val url = "$BASE_URL/${consoleType.libretroThumbnailRepo}/master/Named_Boxarts/$encoded.png" if (downloadFile(url, dest)) { + Log.d(TAG, "Cover art hit for '$gameTitle' via '$name'") return dest.absolutePath } } + Log.d(TAG, "No cover art match for '$gameTitle' after ${namesToTry.size} attempts") return null } @@ -89,13 +103,14 @@ class CoverArtManager(private val context: Context) { private const val TAG = "CoverArtManager" private const val BASE_URL = "https://raw.githubusercontent.com/libretro-thumbnails" - // GoodNES (U) can mean (USA) or (USA, Europe) — try both + // GoodTools-style short region tags can map to several LibRetro long forms. + // Order matters — earlier entries are tried first. private val regionExpansions = mapOf( - "(U)" to listOf("(USA, Europe)", "(USA)"), - "(E)" to listOf("(Europe)", "(Europe, Australia)"), - "(J)" to listOf("(Japan)", "(Japan, USA)"), - "(UE)" to listOf("(USA, Europe)"), - "(JU)" to listOf("(Japan, USA)"), + "(U)" to listOf("(USA)", "(USA, Europe)", "(World)"), + "(E)" to listOf("(Europe)", "(Europe, Australia)", "(World)"), + "(J)" to listOf("(Japan)", "(Japan, USA)", "(World)"), + "(UE)" to listOf("(USA, Europe)", "(USA)", "(World)"), + "(JU)" to listOf("(Japan, USA)", "(USA)", "(Japan)"), "(W)" to listOf("(World)"), "(F)" to listOf("(France)"), "(G)" to listOf("(Germany)"), @@ -104,6 +119,19 @@ class CoverArtManager(private val context: Context) { "(Unl)" to listOf("(USA) (Unl)", "(Unl)"), ) + // Region tags to append to a cleaned (paren-stripped) title as a fallback for ROMs + // whose original filename had no region tag, an unrecognized region tag, or a tag + // that doesn't match what LibRetro stores for that specific game (e.g. SMB1 is + // "(World)" but most US dumps tag it "(U)"). + private val COMMON_LIBRETRO_REGIONS = listOf( + "(USA)", + "(World)", + "(USA, Europe)", + "(Europe)", + "(Japan)", + "(Japan, USA)", + ) + fun expandRegions(title: String): List { val stripped = title .replace(Regex("\\s*\\[[^]]*]"), "") // strip flags like [!], [b], [p1] diff --git a/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt b/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt index fa4fd03..0e1af6f 100644 --- a/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt +++ b/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt @@ -102,16 +102,25 @@ class GameRepository(private val context: Context) { val gamesNeedingArt = _games.value.filter { it.coverArtPath == null } if (gamesNeedingArt.isEmpty()) return - val semaphore = Semaphore(8) + // Concurrency was 8 — combined with parallel bitmap decodes that pinned the heap and + // caused OOM crashes on launch. 2 is plenty for a background fetch. + val semaphore = Semaphore(2) scope.launch { gamesNeedingArt.map { game -> async { semaphore.withPermit { - val path = coverArtManager.fetchCoverArt(game.rawTitle, game.consoleType) - if (path != null) { - _games.value = _games.value.map { - if (it.id == game.id) it.copy(coverArtPath = path) else it + try { + val path = coverArtManager.fetchCoverArt(game.rawTitle, game.consoleType) + if (path != null) { + _games.value = _games.value.map { + if (it.id == game.id) it.copy(coverArtPath = path) else it + } } + } catch (oom: OutOfMemoryError) { + // Don't bring the whole app down for one bad cover art fetch. + android.util.Log.w("GameRepository", "OOM fetching cover for ${game.title}") + } catch (t: Throwable) { + android.util.Log.w("GameRepository", "cover fetch failed for ${game.title}: ${t.message}") } } } diff --git a/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt b/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt index 3de692f..c51b0db 100644 --- a/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt +++ b/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt @@ -1,20 +1,605 @@ package com.lazy.emulate.input +import android.app.PendingIntent +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.hardware.input.InputManager +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.Build +import android.util.Log +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.thread import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private const val TAG = "ControllerManager" + +// Nintendo USB/BT vendor id — Joy-Cons, Pro Controller, NES/SNES Online controllers +private const val VENDOR_NINTENDO = 0x057E + +// 8BitDo USB vendor id — adapter exposes a variety of products under this VID +// depending on current adapter mode (XInput/DInput/macOS/PS Classic). +private const val VENDOR_8BITDO = 0x2DC8 + +// Max lines kept in the in-app raw event log (visible on ControllerTestScreen). +private const val MAX_EVENT_LOG_LINES = 300 + +data class AnalogSnapshot( + val lx: Float = 0f, + val ly: Float = 0f, + val rx: Float = 0f, + val ry: Float = 0f, + val hatX: Float = 0f, + val hatY: Float = 0f +) + +data class UsbDeviceInfo( + val deviceName: String, + val vendorId: Int, + val productId: Int, + val deviceClass: Int, + val deviceSubclass: Int, + val productName: String?, + val manufacturerName: String?, + val interfaces: List +) + +data class UsbInterfaceInfo( + val number: Int, + val interfaceClass: Int, + val interfaceSubclass: Int, + val interfaceProtocol: Int, + val endpointCount: Int +) class ControllerManager( context: Context, var buttonMappingManager: ButtonMappingManager? = null ) : InputManager.InputDeviceListener { - private val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager + private val appContext = context.applicationContext + private val inputManager = appContext.getSystemService(Context.INPUT_SERVICE) as InputManager + private val usbManager = appContext.getSystemService(Context.USB_SERVICE) as UsbManager + + private val usbPermissionAction = "${appContext.packageName}.USB_PERMISSION" + + private val usbPermissionReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != usbPermissionAction) return + @Suppress("DEPRECATION") + val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + appendEventLog("USB permission result: granted=$granted device=${device?.deviceName}") + if (granted && device != null) { + onUsbDeviceAttached(device) + } + } + } + private var usbPermissionReceiverRegistered = false + + private val _usbDevices = MutableStateFlow>(emptyList()) + val usbDevices: StateFlow> = _usbDevices.asStateFlow() + + // USB claim experiment: when a target device is attached via the USB_DEVICE_ATTACHED + // intent, we detach the kernel usbhid driver by calling claimInterface(force=true) and + // hold the claim until release(). Theory: the adapter's mode-cycling is triggered by the + // kernel driver's repeated probes. Holding the interface claim should stop the cycle. + private data class ClaimedDevice( + val device: UsbDevice, + val connection: UsbDeviceConnection, + val interfaces: List + ) + private var claimedDevice: ClaimedDevice? = null + + // Once we successfully claim any target device, refuse all subsequent attach events. + // The adapter cycles modes rapidly when Android can't complete an Xinput handshake; + // without this flag, each cycle would spawn a new reader thread and eventually OOM. + private var hasActiveClaim = false + + // Xinput reader state — runs on a background thread reading 20-byte reports from the + // Xbox-360-style interrupt IN endpoint on interface #0, parsing them, and dispatching + // GamepadButton / analog events into the normal event pipeline. + private var xinputReaderThread: Thread? = null + private val xinputReaderStop = AtomicBoolean(false) + private var xinputPrevButtons = 0 + private var xinputPrevL2 = false + private var xinputPrevR2 = false + + // Xinput button bit layout + private val XINPUT_DPAD_UP = 0x0001 + private val XINPUT_DPAD_DOWN = 0x0002 + private val XINPUT_DPAD_LEFT = 0x0004 + private val XINPUT_DPAD_RIGHT = 0x0008 + private val XINPUT_START = 0x0010 + private val XINPUT_BACK = 0x0020 + private val XINPUT_LTHUMB = 0x0040 + private val XINPUT_RTHUMB = 0x0080 + private val XINPUT_LB = 0x0100 + private val XINPUT_RB = 0x0200 + private val XINPUT_GUIDE = 0x0400 + private val XINPUT_A = 0x1000 + private val XINPUT_B = 0x2000 + private val XINPUT_X = 0x4000 + private val XINPUT_Y = 0x8000 + + private val xinputBitToButton: List> = listOf( + XINPUT_DPAD_UP to GamepadButton.DPAD_UP, + XINPUT_DPAD_DOWN to GamepadButton.DPAD_DOWN, + XINPUT_DPAD_LEFT to GamepadButton.DPAD_LEFT, + XINPUT_DPAD_RIGHT to GamepadButton.DPAD_RIGHT, + XINPUT_START to GamepadButton.START, + XINPUT_BACK to GamepadButton.SELECT, + XINPUT_LTHUMB to GamepadButton.L3, + XINPUT_RTHUMB to GamepadButton.R3, + XINPUT_LB to GamepadButton.L1, + XINPUT_RB to GamepadButton.R1, + XINPUT_A to GamepadButton.FACE_BOTTOM, + XINPUT_B to GamepadButton.FACE_RIGHT, + XINPUT_X to GamepadButton.FACE_LEFT, + XINPUT_Y to GamepadButton.FACE_TOP + ) + + // Debounce: only request USB permission once per vid/pid per session. The cycling adapter + // generates ATTACH broadcasts ~5/sec, which otherwise floods Android with permission + // dialogs and OOMs the app. + private val permissionRequestedFor = mutableSetOf() + + // Also rate-limit onUsbDeviceAttached itself — the broadcast can fire dozens of times per + // second. Drop calls that arrive within 500 ms of the previous one for the same vid/pid. + private val lastAttachTimeByKey = mutableMapOf() + private val attachThrottleMs = 500L + + private val targetVidPids: Set> = setOf( + 0x2DC8 to 0x3106, // 8BitDo Pro 2 Wired — D-input + 0x2DC8 to 0x3107, // 8BitDo IDLE state + 0x2DC8 to 0x3105, // 8BitDo ...? + 0x045E to 0x028E, // Xbox 360 controller — X-input + 0x045E to 0x02e0, // Xbox 360 controller — also X-input + 0x054C to 0x05C4 // Sony DualShock 4 — what the adapter latched to on macOS + ) + + private fun isTargetDevice(device: UsbDevice): Boolean = + (device.vendorId to device.productId) in targetVidPids + + /** Sweep already-connected USB devices — called on resume. */ + fun tryClaimAlreadyConnectedUsb() { + for (device in usbManager.deviceList.values) { + if (isTargetDevice(device) && claimedDevice?.device?.deviceName != device.deviceName) { + appendEventLog("USB sweep found target device ${device.deviceName}, attaching") + onUsbDeviceAttached(device) + } + } + } + + fun onUsbDeviceAttached(device: UsbDevice) { + // Ignore USB events that aren't our target adapter. + if (!isTargetDevice(device)) { + Log.d(TAG, "ignoring non-target USB device v=${device.vendorId} p=${device.productId}") + return + } + + // One claim per session. The cycling adapter otherwise thrashes threads and OOMs. + if (hasActiveClaim) { + Log.d(TAG, "hasActiveClaim=true, ignoring new attach for ${device.deviceName}") + return + } + + // If we already hold a claim on this exact device, nothing to do. + if (claimedDevice?.device?.deviceName == device.deviceName) { + Log.d(TAG, "already claimed ${device.deviceName}, ignoring re-attach") + return + } + + // Throttle: the cycling adapter fires ATTACH broadcasts every ~400 ms. + // Drop duplicate calls within the throttle window to avoid flooding the system. + val key = "${device.vendorId}:${device.productId}" + val now = System.currentTimeMillis() + val last = lastAttachTimeByKey[key] ?: 0L + if (now - last < attachThrottleMs) { + Log.d(TAG, "throttled re-attach for $key (${now - last}ms since last)") + return + } + lastAttachTimeByKey[key] = now + + appendEventLog( + "USB ATTACH '${device.productName ?: device.deviceName}' " + + "v=0x${"%04x".format(device.vendorId)} p=0x${"%04x".format(device.productId)}" + ) + Log.d(TAG, "onUsbDeviceAttached ${device.deviceName} v=${device.vendorId} p=${device.productId}") + + // Enumerate interfaces once so we can decide whether to claim. + val allIfaces = (0 until device.interfaceCount).map { device.getInterface(it) } + for (i in allIfaces) { + appendEventLog( + " iface #${i.id} class=${i.interfaceClass}(${usbClassName(i.interfaceClass)}) " + + "sub=${i.interfaceSubclass} proto=${i.interfaceProtocol} eps=${i.endpointCount}" + ) + } + + // If any interface is standard HID (class=3), this is an 8BitDo in DInput / macOS / + // PS-Classic mode. On Samsung Fold 6, the kernel's usbhid driver ONLY binds the + // PS-Classic variant (vid=0x054c pid=0x0cda, Sony Interactive Entertainment Controller) + // — DInput and macOS modes are silently refused. In PS-Classic mode Android creates + // a proper GAMEPAD InputDevice and events flow through dispatchKeyEvent → handleKeyEvent. + // + // Switch the adapter to PS-Classic mode with Select + D-Pad Down (3 seconds). + // + // In any HID case we do NOT claim the interface: claiming detaches whatever kernel + // driver may have bound and turns a working controller into a dead one. + val hasHidInterface = allIfaces.any { it.interfaceClass == UsbConstants.USB_CLASS_HID } + if (hasHidInterface) { + appendEventLog( + "USB device has HID interface — letting Android's usbhid driver handle it " + + "(no claim). Use PS-Classic mode (Select+Down) if events don't appear." + ) + hasActiveClaim = true + refreshUsbDevices() + return + } + + // Release any prior claim (e.g. the adapter just re-enumerated in a new mode). + releaseClaim() + + if (!usbManager.hasPermission(device)) { + // Only request permission once per vid/pid — flooding Android with dialogs + // causes OOM. + if (key in permissionRequestedFor) { + appendEventLog("USB permission still missing, already requested once — skipping") + return + } + permissionRequestedFor.add(key) + appendEventLog("USB permission missing — requesting (one-shot)") + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val pi = PendingIntent.getBroadcast( + appContext, + 0, + Intent(usbPermissionAction).apply { setPackage(appContext.packageName) }, + flags + ) + try { + usbManager.requestPermission(device, pi) + } catch (t: Throwable) { + appendEventLog("requestPermission threw: ${t.message}") + } + return + } + + val connection = try { + usbManager.openDevice(device) + } catch (t: Throwable) { + appendEventLog("USB openDevice threw: ${t.message}") + return + } + if (connection == null) { + appendEventLog("USB openDevice returned null") + return + } + + // Only claim the main controller input interface (vendor-spec, subclass 0x5D, + // protocol 0x01). Claiming the headset / security interfaces appears to trigger + // re-enumeration on the adapter. + val mainIface = allIfaces.firstOrNull { + it.interfaceClass == 0xFF && it.interfaceSubclass == 0x5D && it.interfaceProtocol == 0x01 + } ?: allIfaces.firstOrNull() + + if (mainIface == null) { + appendEventLog("USB no interfaces to claim, closing") + connection.close() + return + } + + val ok = try { + connection.claimInterface(mainIface, true) + } catch (t: Throwable) { + appendEventLog("claimInterface #${mainIface.id} threw: ${t.message}") + false + } + appendEventLog( + "USB claimInterface #${mainIface.id} " + + "class=${mainIface.interfaceClass} sub=${mainIface.interfaceSubclass} " + + "proto=${mainIface.interfaceProtocol} → ${if (ok) "OK (kernel detached)" else "FAIL"}" + ) + if (!ok) { + connection.close() + return + } + + claimedDevice = ClaimedDevice(device, connection, listOf(mainIface)) + hasActiveClaim = true + appendEventLog("USB holding claim on 1 interface.") + startXinputReader(connection, listOf(mainIface)) + // Refresh the in-app USB list so the UI reflects the new state. + refreshUsbDevices() + } + + fun releaseClaim() { + val c = claimedDevice ?: return + appendEventLog("USB releasing claim on '${c.device.productName ?: c.device.deviceName}'") + + // Close the connection BEFORE joining the reader thread — this unblocks any + // in-flight bulkTransfer call so the thread can actually exit. + xinputReaderStop.set(true) + for (iface in c.interfaces) { + try { + c.connection.releaseInterface(iface) + } catch (t: Throwable) { + Log.w(TAG, "releaseInterface failed", t) + } + } + try { + c.connection.close() + } catch (t: Throwable) { + Log.w(TAG, "connection.close failed", t) + } + + // Now that the connection is dead, the reader's bulkTransfer will fail fast. + stopXinputReader() + + claimedDevice = null + hasActiveClaim = false + // Clear any buttons left pressed by the reader thread. + _pressedButtons.value = emptySet() + _analogSnapshot.value = AnalogSnapshot() + xinputPrevButtons = 0 + xinputPrevL2 = false + xinputPrevR2 = false + } + + /** + * Locate the interrupt IN endpoint on the Xbox-360-style main controller interface + * (class 0xFF / subclass 0x5D / protocol 0x01) and spin up a reader thread. + */ + private fun startXinputReader(connection: UsbDeviceConnection, interfaces: List) { + // Main controller interface: vendor-specific, subclass 0x5D, protocol 0x01. + val mainIface = interfaces.firstOrNull { + it.interfaceClass == 0xFF && it.interfaceSubclass == 0x5D && it.interfaceProtocol == 0x01 + } ?: interfaces.firstOrNull() + if (mainIface == null) { + appendEventLog("Xinput reader: no suitable interface") + return + } + var inEndpoint: UsbEndpoint? = null + var outEndpoint: UsbEndpoint? = null + for (i in 0 until mainIface.endpointCount) { + val ep = mainIface.getEndpoint(i) + if (ep.direction == UsbConstants.USB_DIR_IN && + (ep.type == UsbConstants.USB_ENDPOINT_XFER_INT || ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK) + ) { + inEndpoint = ep + } + if (ep.direction == UsbConstants.USB_DIR_OUT && + (ep.type == UsbConstants.USB_ENDPOINT_XFER_INT || ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK) + ) { + outEndpoint = ep + } + } + if (inEndpoint == null) { + appendEventLog("Xinput reader: no IN endpoint on iface #${mainIface.id}") + return + } + appendEventLog( + "Xinput reader starting on iface #${mainIface.id} " + + "endpoint addr=0x${"%02x".format(inEndpoint.address)} " + + "maxPkt=${inEndpoint.maxPacketSize}" + ) + + // Xbox 360 wired controllers need an LED init before they emit reports. + // The real command is an interrupt OUT write (NOT a HID SET_REPORT), so we use + // bulkTransfer against the OUT endpoint — UsbDeviceConnection.bulkTransfer handles + // both bulk and interrupt endpoints. + if (outEndpoint != null) { + // LED pattern 0x06 = player 1 blinking → solid + val ledCommand = byteArrayOf(0x01, 0x03, 0x06) + val sent = connection.bulkTransfer(outEndpoint, ledCommand, ledCommand.size, 100) + appendEventLog("Xinput LED init bulkTransfer(OUT) → $sent bytes") + } + + xinputPrevButtons = 0 + xinputPrevL2 = false + xinputPrevR2 = false + xinputReaderStop.set(false) + val ep = inEndpoint + xinputReaderThread = thread(name = "XinputReader", isDaemon = true) { + val buf = ByteArray(32) + var consecutiveErrors = 0 + var totalPackets = 0 + try { + while (!xinputReaderStop.get()) { + val n = try { + connection.bulkTransfer(ep, buf, buf.size, 200) + } catch (t: Throwable) { + Log.w(TAG, "bulkTransfer threw", t) + -1 + } + when { + n >= 20 && buf[0].toInt() == 0x00 && buf[1].toInt() == 0x14 -> { + consecutiveErrors = 0 + totalPackets++ + if (totalPackets == 1) { + Log.d(TAG, "XinputReader: first valid packet") + } + parseXinputReport(buf) + } + n >= 0 -> { + // Short / non-input packet (heartbeat, LED status, etc). + consecutiveErrors = 0 + } + else -> { + consecutiveErrors++ + // After sustained failures the device is gone. Exit the thread + // instead of spinning forever. + if (consecutiveErrors > 100) { + Log.w(TAG, "XinputReader: $consecutiveErrors consecutive errors, giving up") + break + } + } + } + } + } finally { + Log.d(TAG, "XinputReader thread exiting (packets=$totalPackets)") + } + } + } + + private fun stopXinputReader() { + if (xinputReaderThread == null) return + xinputReaderStop.set(true) + try { + xinputReaderThread?.join(500) + } catch (_: InterruptedException) {} + xinputReaderThread = null + } + + private fun parseXinputReport(buf: ByteArray) { + // buf[0] = 0x00 (type), buf[1] = 0x14 (len) + val buttons = (buf[2].toInt() and 0xFF) or ((buf[3].toInt() and 0xFF) shl 8) + val lTrigger = buf[4].toInt() and 0xFF + val rTrigger = buf[5].toInt() and 0xFF + val lx = readInt16LE(buf, 6) + val ly = readInt16LE(buf, 8) + val rx = readInt16LE(buf, 10) + val ry = readInt16LE(buf, 12) + + // Button diff + dispatch + val changed = buttons xor xinputPrevButtons + if (changed != 0) { + for ((bit, gamepadBtn) in xinputBitToButton) { + if (changed and bit != 0) { + val nowDown = (buttons and bit) != 0 + dispatchButton(gamepadBtn, nowDown) + } + } + xinputPrevButtons = buttons + } + + // Triggers as discrete L2/R2 with a deadzone threshold + val l2Down = lTrigger > 30 + val r2Down = rTrigger > 30 + if (l2Down != xinputPrevL2) { + dispatchButton(GamepadButton.L2, l2Down) + xinputPrevL2 = l2Down + } + if (r2Down != xinputPrevR2) { + dispatchButton(GamepadButton.R2, r2Down) + xinputPrevR2 = r2Down + } + + // Sticks — normalize int16 → float [-1, 1] and negate Y to match Android conventions + // (Xinput: Y positive = up; Android input: Y positive = down). + val nLx = lx.toFloat() / 32767f + val nLy = -ly.toFloat() / 32767f + val nRx = rx.toFloat() / 32767f + val nRy = -ry.toFloat() / 32767f + + onAnalogEvent?.invoke(nLx, nLy, 0) + onAnalogEvent?.invoke(nRx, nRy, 1) + _analogSnapshot.value = AnalogSnapshot(nLx, nLy, nRx, nRy, 0f, 0f) + } + + private fun readInt16LE(buf: ByteArray, offset: Int): Int { + val lo = buf[offset].toInt() and 0xFF + val hi = buf[offset + 1].toInt() // preserve sign + return (hi shl 8) or lo + } + + private fun dispatchButton(button: GamepadButton, isDown: Boolean) { + if (isDown) { + _pressedButtons.value = _pressedButtons.value + button + } else { + _pressedButtons.value = _pressedButtons.value - button + } + onButtonEvent?.invoke(button, isDown) + } + + fun refreshUsbDevices() { + val snapshot = try { + usbManager.deviceList.values.map { device -> device.toInfo() } + } catch (t: Throwable) { + Log.w(TAG, "usbManager.deviceList threw", t) + emptyList() + } + _usbDevices.value = snapshot + appendEventLog("--- USB enumeration (${snapshot.size} device${if (snapshot.size == 1) "" else "s"}) ---") + for (d in snapshot) { + val classStr = usbClassName(d.deviceClass) + appendEventLog( + "USB '${d.productName ?: d.deviceName}' " + + "v=0x${"%04x".format(d.vendorId)} p=0x${"%04x".format(d.productId)} " + + "class=${d.deviceClass}($classStr) sub=${d.deviceSubclass} " + + "ifaces=${d.interfaces.size}" + ) + for (i in d.interfaces) { + val iclassStr = usbClassName(i.interfaceClass) + appendEventLog( + " iface #${i.number} class=${i.interfaceClass}($iclassStr) " + + "sub=${i.interfaceSubclass} proto=${i.interfaceProtocol} endpoints=${i.endpointCount}" + ) + } + } + } + + private fun UsbDevice.toInfo(): UsbDeviceInfo { + val interfaces = (0 until interfaceCount).map { idx -> + val iface = getInterface(idx) + UsbInterfaceInfo( + number = iface.id, + interfaceClass = iface.interfaceClass, + interfaceSubclass = iface.interfaceSubclass, + interfaceProtocol = iface.interfaceProtocol, + endpointCount = iface.endpointCount + ) + } + return UsbDeviceInfo( + deviceName = deviceName, + vendorId = vendorId, + productId = productId, + deviceClass = deviceClass, + deviceSubclass = deviceSubclass, + productName = try { productName } catch (t: Throwable) { null }, + manufacturerName = try { manufacturerName } catch (t: Throwable) { null }, + interfaces = interfaces + ) + } + + private fun usbClassName(c: Int): String = when (c) { + UsbConstants.USB_CLASS_PER_INTERFACE -> "per-interface" + UsbConstants.USB_CLASS_AUDIO -> "audio" + UsbConstants.USB_CLASS_COMM -> "comm" + UsbConstants.USB_CLASS_HID -> "HID" + UsbConstants.USB_CLASS_PHYSICA -> "physical" + UsbConstants.USB_CLASS_STILL_IMAGE -> "still-image" + UsbConstants.USB_CLASS_PRINTER -> "printer" + UsbConstants.USB_CLASS_MASS_STORAGE -> "mass-storage" + UsbConstants.USB_CLASS_HUB -> "hub" + UsbConstants.USB_CLASS_CDC_DATA -> "cdc-data" + UsbConstants.USB_CLASS_CSCID -> "smartcard" + UsbConstants.USB_CLASS_CONTENT_SEC -> "content-sec" + UsbConstants.USB_CLASS_VIDEO -> "video" + UsbConstants.USB_CLASS_WIRELESS_CONTROLLER -> "wireless-ctl" + UsbConstants.USB_CLASS_MISC -> "misc" + UsbConstants.USB_CLASS_APP_SPEC -> "app-specific" + UsbConstants.USB_CLASS_VENDOR_SPEC -> "VENDOR-SPEC (xinput?)" + 0 -> "use-iface-class" + else -> "class-$c" + } private val _connectedControllers = MutableStateFlow>(emptyList()) val connectedControllers: StateFlow> = _connectedControllers.asStateFlow() @@ -22,22 +607,79 @@ class ControllerManager( private val _activeController = MutableStateFlow(null) val activeController: StateFlow = _activeController.asStateFlow() + // Raw-event ring buffer shown on ControllerTestScreen. Mirrors the logcat debug output. + private val _rawEventLog = MutableStateFlow>(emptyList()) + val rawEventLog: StateFlow> = _rawEventLog.asStateFlow() + + // Currently pressed mapped buttons — drives the live indicator on the test screen. + private val _pressedButtons = MutableStateFlow>(emptySet()) + val pressedButtons: StateFlow> = _pressedButtons.asStateFlow() + + // Most recent axis values, for analog readout on the test screen. + private val _analogSnapshot = MutableStateFlow(AnalogSnapshot()) + val analogSnapshot: StateFlow = _analogSnapshot.asStateFlow() + + private val timestampFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + var onButtonEvent: ((GamepadButton, Boolean) -> Unit)? = null var onAnalogEvent: ((Float, Float, Int) -> Unit)? = null + fun clearEventLog() { + _rawEventLog.value = emptyList() + } + + private fun appendEventLog(line: String) { + val stamped = "${timestampFormat.format(Date())} $line" + // Mirror in-app log to logcat so we can see the full claim flow from adb. + Log.d(TAG, "LOG: $line") + val current = _rawEventLog.value + val next = current + stamped + _rawEventLog.value = if (next.size > MAX_EVENT_LOG_LINES) { + next.takeLast(MAX_EVENT_LOG_LINES) + } else { + next + } + } + fun start() { inputManager.registerInputDeviceListener(this, null) refreshControllers() + if (!usbPermissionReceiverRegistered) { + val filter = IntentFilter(usbPermissionAction) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + appContext.registerReceiver(usbPermissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + appContext.registerReceiver(usbPermissionReceiver, filter) + } + usbPermissionReceiverRegistered = true + } } fun stop() { inputManager.unregisterInputDeviceListener(this) + if (usbPermissionReceiverRegistered) { + try { appContext.unregisterReceiver(usbPermissionReceiver) } catch (_: Throwable) {} + usbPermissionReceiverRegistered = false + } } private fun refreshControllers() { - val controllers = InputDevice.getDeviceIds() + val allDevices = InputDevice.getDeviceIds() .toList() .mapNotNull { InputDevice.getDevice(it) } + + // Log every device we see, so we can tell why the Joy-Con is or isn't being accepted. + appendEventLog("--- device enumeration ---") + for (d in allDevices) { + val line = "DEV id=${d.id} '${d.name}' " + + "v=0x${"%04x".format(d.vendorId)} p=0x${"%04x".format(d.productId)} " + + "src=0x${"%08x".format(d.sources)} ext=${d.isExternal} ok=${isGameController(d)}" + Log.d(TAG, line) + appendEventLog(line) + } + + val controllers = allDevices .filter { isGameController(it) } .map { device -> GameController( @@ -55,47 +697,111 @@ class ControllerManager( } fun handleKeyEvent(event: KeyEvent): Boolean { - if (!isGameControllerDevice(event.device ?: return false)) return false + val device = event.device ?: return false - val rawButton = mapKeyToButton(event.keyCode) ?: return false + // Log every key event from every device (both to logcat and the in-app ring buffer) + // so we can see exactly what a controller emits even when it isn't yet mapped. + val actionStr = when (event.action) { + KeyEvent.ACTION_DOWN -> "DOWN" + KeyEvent.ACTION_UP -> "UP" + else -> "A${event.action}" + } + val keyLine = "KEY $actionStr " + + "dev=${device.id}('${device.name}') " + + "v=0x${"%04x".format(device.vendorId)} p=0x${"%04x".format(device.productId)} " + + "code=${event.keyCode}(${KeyEvent.keyCodeToString(event.keyCode)}) " + + "scan=${event.scanCode}" + Log.d(TAG, keyLine) + appendEventLog(keyLine) + + if (!isGameControllerDevice(device)) return false + + val mapped = mapKeyToButton(event.keyCode) ?: return false + // Per-device fixups for the 8BitDo USB Adapter 2 in PS-Classic mode (Sony Interactive + // Entertainment Controller). The adapter routes the NES Joy-Con's buttons through a + // 6-button-style layout that doesn't match what game cores expect: + // - Joy-Con `-` / `+` come through as BTN_TL2/TR2 → KEYCODE_BUTTON_L2/R2. There are + // no real triggers on this controller, so reinterpret those as Select/Start — + // otherwise the Joy-Con can't send Select/Start at all (Home/Capture get eaten by + // the OS via the Consumer Control sibling node). + // - Joy-Con B (the bottom-right face button) comes through as BTN_C → BUTTON_C → + // C_BUTTON. Most cores have no mapping for C_BUTTON, so remap it to FACE_BOTTOM + // so it acts as the "south" face button (NES B / SNES B / PS Cross). + val rawButton = if (isDpadOnPrimaryAxisDevice(device)) { + when (mapped) { + GamepadButton.L2 -> GamepadButton.SELECT + GamepadButton.R2 -> GamepadButton.START + GamepadButton.C_BUTTON -> GamepadButton.FACE_BOTTOM + else -> mapped + } + } else mapped val button = buttonMappingManager?.remapControllerButton(rawButton) ?: rawButton when (event.action) { - KeyEvent.ACTION_DOWN -> onButtonEvent?.invoke(button, true) - KeyEvent.ACTION_UP -> onButtonEvent?.invoke(button, false) + KeyEvent.ACTION_DOWN -> dispatchButton(button, true) + KeyEvent.ACTION_UP -> dispatchButton(button, false) } return true } fun handleMotionEvent(event: MotionEvent): Boolean { - if (!isGameControllerDevice(event.device ?: return false)) return false + val device = event.device ?: return false - // Left stick val lx = event.getAxisValue(MotionEvent.AXIS_X) val ly = event.getAxisValue(MotionEvent.AXIS_Y) - if (lx != 0f || ly != 0f) { - onAnalogEvent?.invoke(lx, ly, 0) - } - - // Right stick val rx = event.getAxisValue(MotionEvent.AXIS_Z) val ry = event.getAxisValue(MotionEvent.AXIS_RZ) - if (rx != 0f || ry != 0f) { - onAnalogEvent?.invoke(rx, ry, 1) - } - - // D-pad via axes (some controllers report dpad as axis) val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X) val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y) - handleDpadAxis(hatX, hatY) + val anyNonZero = lx != 0f || ly != 0f || rx != 0f || ry != 0f || hatX != 0f || hatY != 0f + + if (anyNonZero) { + val motionLine = "MOT dev=${device.id}('${device.name}') " + + "L=(${"%.2f".format(lx)},${"%.2f".format(ly)}) " + + "R=(${"%.2f".format(rx)},${"%.2f".format(ry)}) " + + "HAT=(${"%.2f".format(hatX)},${"%.2f".format(hatY)})" + Log.d(TAG, motionLine) + appendEventLog(motionLine) + _analogSnapshot.value = AnalogSnapshot(lx, ly, rx, ry, hatX, hatY) + } + + if (!isGameControllerDevice(device)) return false + + if (isDpadOnPrimaryAxisDevice(device)) { + // Sony PS Classic / 8BitDo in PS-Classic mode reports its dpad as raw ABS_X/ABS_Y + // (range 0..2, normalized to -1..+1) and has no real analog sticks. Route the + // primary axis to the dpad handler instead of the left stick. + handleDpadAxis(lx, ly) + } else { + if (lx != 0f || ly != 0f) { + onAnalogEvent?.invoke(lx, ly, 0) + } + if (rx != 0f || ry != 0f) { + onAnalogEvent?.invoke(rx, ry, 1) + } + handleDpadAxis(hatX, hatY) + } return true } + /** + * True for devices whose dpad is reported on AXIS_X/AXIS_Y rather than AXIS_HAT_X/Y, + * and which have no real analog sticks. Currently: 8BitDo USB Adapter 2 in PS-Classic + * mode (Select+Down 3s), which impersonates the Sony Interactive Entertainment + * Controller (vid=0x054C, pid=0x0CDA). + */ + private fun isDpadOnPrimaryAxisDevice(device: InputDevice): Boolean { + return device.vendorId == 0x054C && device.productId == 0x0CDA + } + private fun handleDpadAxis(hatX: Float, hatY: Float) { - onButtonEvent?.invoke(GamepadButton.DPAD_LEFT, hatX < -0.5f) - onButtonEvent?.invoke(GamepadButton.DPAD_RIGHT, hatX > 0.5f) - onButtonEvent?.invoke(GamepadButton.DPAD_UP, hatY < -0.5f) - onButtonEvent?.invoke(GamepadButton.DPAD_DOWN, hatY > 0.5f) + // Route through dispatchButton so the test screen's _pressedButtons reflects dpad + // state, the buttonMappingManager remap path applies, and the emulator core sees + // the events via onButtonEvent — same path as physical key events. + dispatchButton(GamepadButton.DPAD_LEFT, hatX < -0.5f) + dispatchButton(GamepadButton.DPAD_RIGHT, hatX > 0.5f) + dispatchButton(GamepadButton.DPAD_UP, hatY < -0.5f) + dispatchButton(GamepadButton.DPAD_DOWN, hatY > 0.5f) } fun setActiveController(controller: GameController) { @@ -106,8 +812,10 @@ class ControllerManager( return when (keyCode) { KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER -> GamepadButton.FACE_BOTTOM KeyEvent.KEYCODE_BUTTON_B -> GamepadButton.FACE_RIGHT + KeyEvent.KEYCODE_BUTTON_C -> GamepadButton.C_BUTTON KeyEvent.KEYCODE_BUTTON_X -> GamepadButton.FACE_LEFT KeyEvent.KEYCODE_BUTTON_Y -> GamepadButton.FACE_TOP + KeyEvent.KEYCODE_BUTTON_Z -> GamepadButton.Z_BUTTON KeyEvent.KEYCODE_BUTTON_L1 -> GamepadButton.L1 KeyEvent.KEYCODE_BUTTON_R1 -> GamepadButton.R1 KeyEvent.KEYCODE_BUTTON_L2 -> GamepadButton.L2 @@ -125,9 +833,21 @@ class ControllerManager( } private fun isGameController(device: InputDevice): Boolean { + if (device.isVirtual) return false val sources = device.sources - return (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD || - (sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK + val isGamepad = (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD + val isJoystick = (sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK + if (isGamepad || isJoystick) return true + + // Nintendo Joy-Cons and the NES/SNES Online controllers frequently register as + // keyboard-only on Android — accept them by vendor id so their events reach us. + if (device.vendorId == VENDOR_NINTENDO) return true + + // 8BitDo adapters in DInput mode (and bare 8BitDo controllers) sometimes don't + // get GAMEPAD/JOYSTICK bits set on Samsung OneUI — accept by vendor id. + if (device.vendorId == VENDOR_8BITDO) return true + + return false } private fun isGameControllerDevice(device: InputDevice): Boolean = isGameController(device) diff --git a/app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt b/app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt index 8f3010e..ea79793 100644 --- a/app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt +++ b/app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt @@ -24,9 +24,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import coil.request.ImageRequest +import coil.size.Scale import com.lazy.emulate.data.model.Game import java.io.File @@ -55,8 +58,16 @@ fun GameCard( contentAlignment = Alignment.Center ) { if (game.coverArtPath != null) { + // Cap decode size — LibRetro thumbnail PNGs can be 1500x2000+, which + // burns ~12 MB of bitmap memory each. With 30 cards visible that's + // hundreds of MB and was OOM-ing the app during in-game input delivery. + // 384px is more than enough for a tile in a grid. AsyncImage( - model = File(game.coverArtPath), + model = ImageRequest.Builder(LocalContext.current) + .data(File(game.coverArtPath)) + .size(384) + .scale(Scale.FILL) + .build(), contentDescription = "${game.title} cover art", contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() diff --git a/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt b/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt index e1e8b1e..4a89a3c 100644 --- a/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt @@ -13,6 +13,7 @@ import com.lazy.emulate.emulation.ConsoleType import com.lazy.emulate.input.ButtonMappingManager import com.lazy.emulate.input.ControllerManager import com.lazy.emulate.ui.screens.controller.ControllerLayoutScreen +import com.lazy.emulate.ui.screens.controller.ControllerTestScreen import com.lazy.emulate.ui.screens.game.GameScreen import com.lazy.emulate.ui.screens.home.HomeScreen import com.lazy.emulate.ui.screens.settings.SettingsScreen @@ -51,6 +52,14 @@ fun EmulateNavGraph( controllerManager = controllerManager, preferencesManager = preferencesManager, buttonMappingManager = buttonMappingManager, + onBack = { navController.popBackStack() }, + onTestController = { navController.navigate(Screen.ControllerTest.route) } + ) + } + + composable(Screen.ControllerTest.route) { + ControllerTestScreen( + controllerManager = controllerManager, onBack = { navController.popBackStack() } ) } diff --git a/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt b/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt index 15fbc3f..2abf34d 100644 --- a/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt +++ b/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt @@ -3,6 +3,7 @@ package com.lazy.emulate.ui.navigation sealed class Screen(val route: String) { data object Home : Screen("home") data object Settings : Screen("settings") + data object ControllerTest : Screen("controller_test") data object ControllerLayout : Screen("controller_layout/{consoleType}") { fun createRoute(consoleType: String) = "controller_layout/$consoleType" } diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerTestScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerTestScreen.kt new file mode 100644 index 0000000..bffcaf8 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerTestScreen.kt @@ -0,0 +1,364 @@ +package com.lazy.emulate.ui.screens.controller + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.lazy.emulate.input.ControllerManager +import com.lazy.emulate.input.GamepadButton +import com.lazy.emulate.input.UsbDeviceInfo + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ControllerTestScreen( + controllerManager: ControllerManager, + onBack: () -> Unit +) { + val controllers by controllerManager.connectedControllers.collectAsState() + val pressed by controllerManager.pressedButtons.collectAsState() + val analog by controllerManager.analogSnapshot.collectAsState() + val log by controllerManager.rawEventLog.collectAsState() + val usbDevices by controllerManager.usbDevices.collectAsState() + + LaunchedEffect(Unit) { controllerManager.refreshUsbDevices() } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Test Controller") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { controllerManager.clearEventLog() }) { + Icon(Icons.Default.Delete, contentDescription = "Clear log") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + // Detected devices + Text( + "Detected Controllers (${controllers.size})", + style = MaterialTheme.typography.titleMedium + ) + Spacer(Modifier.height(6.dp)) + Card(modifier = Modifier.fillMaxWidth()) { + if (controllers.isEmpty()) { + Text( + "None — press a button or re-pair the controller.", + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + controllers.forEachIndexed { i, c -> + Column(modifier = Modifier.padding(12.dp)) { + Text(c.name, fontWeight = FontWeight.SemiBold) + Text( + "vendor=0x${"%04x".format(c.vendorId)} product=0x${"%04x".format(c.productId)} id=${c.deviceId} ${if (c.isExternal) "external" else "internal"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (i < controllers.lastIndex) HorizontalDivider() + } + } + } + + Spacer(Modifier.height(16.dp)) + + // USB devices — helps diagnose whether a USB host-mode adapter is enumerating. + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + "USB Devices (${usbDevices.size})", + style = MaterialTheme.typography.titleMedium + ) + IconButton(onClick = { controllerManager.refreshUsbDevices() }) { + Icon(Icons.Default.Refresh, contentDescription = "Rescan USB") + } + } + Spacer(Modifier.height(6.dp)) + Card(modifier = Modifier.fillMaxWidth()) { + if (usbDevices.isEmpty()) { + Text( + "No USB host devices visible to the app. If you have an OTG adapter plugged in, tap rescan.", + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Column(modifier = Modifier.padding(12.dp)) { + usbDevices.forEachIndexed { i, d -> + UsbDeviceRow(d) + if (i < usbDevices.lastIndex) Spacer(Modifier.height(8.dp)) + } + } + } + } + + Spacer(Modifier.height(16.dp)) + + // Live button indicators + Text("Pressed Buttons", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(6.dp)) + ButtonGrid(pressed = pressed) + + Spacer(Modifier.height(16.dp)) + + // Analog readout + Text("Axes", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(6.dp)) + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + AxisRow("Left stick", analog.lx, analog.ly) + AxisRow("Right stick", analog.rx, analog.ry) + AxisRow("D-pad (hat)", analog.hatX, analog.hatY) + } + } + + Spacer(Modifier.height(16.dp)) + + // Raw event log + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Raw Events (${log.size})", style = MaterialTheme.typography.titleMedium) + } + Spacer(Modifier.height(6.dp)) + + val listState = rememberLazyListState() + LaunchedEffect(log.size) { + if (log.isNotEmpty()) { + listState.scrollToItem(log.size - 1) + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 180.dp, max = 360.dp) + .padding(8.dp) + ) { + if (log.isEmpty()) { + item { + Text( + "No events yet. Press any button or move any axis on the controller.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + items(log) { line -> + Text( + text = line, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } + } +} + +@Composable +private fun ButtonGrid(pressed: Set) { + val rows = listOf( + listOf(GamepadButton.DPAD_UP, GamepadButton.DPAD_DOWN, GamepadButton.DPAD_LEFT, GamepadButton.DPAD_RIGHT), + listOf(GamepadButton.FACE_TOP, GamepadButton.FACE_BOTTOM, GamepadButton.FACE_LEFT, GamepadButton.FACE_RIGHT), + listOf(GamepadButton.C_BUTTON, GamepadButton.Z_BUTTON, GamepadButton.MODE), + listOf(GamepadButton.L1, GamepadButton.R1, GamepadButton.L2, GamepadButton.R2), + listOf(GamepadButton.L3, GamepadButton.R3, GamepadButton.START, GamepadButton.SELECT) + ) + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(8.dp)) { + rows.forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + row.forEach { btn -> + ButtonChip( + label = shortLabel(btn), + active = btn in pressed, + modifier = Modifier.weight(1f) + ) + } + } + Spacer(Modifier.height(6.dp)) + } + } + } +} + +@Composable +private fun ButtonChip(label: String, active: Boolean, modifier: Modifier = Modifier) { + val bg = if (active) MaterialTheme.colorScheme.primary else Color.Transparent + val fg = if (active) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + Box( + modifier = modifier + .height(36.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(6.dp) + ) + .background(bg, RoundedCornerShape(6.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + color = fg, + fontSize = 12.sp, + fontWeight = if (active) FontWeight.Bold else FontWeight.Normal + ) + } +} + +@Composable +private fun UsbDeviceRow(d: UsbDeviceInfo) { + val classLabel = when (d.deviceClass) { + 3 -> "HID" + 0 -> "use-iface" + 0xFF -> "VENDOR (xinput?)" + 9 -> "hub" + else -> "class-${d.deviceClass}" + } + val isHid = d.deviceClass == 3 || d.interfaces.any { it.interfaceClass == 3 } + Column { + Text( + d.productName ?: d.deviceName, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyMedium + ) + Text( + "v=0x${"%04x".format(d.vendorId)} p=0x${"%04x".format(d.productId)} " + + "devClass=${d.deviceClass} ($classLabel) " + + if (isHid) "HID ✓" else "NO HID", + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = if (isHid) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.error + ) + d.manufacturerName?.let { + Text( + "mfr: $it", + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + d.interfaces.forEach { i -> + val iLabel = when (i.interfaceClass) { + 3 -> "HID" + 0xFF -> "VENDOR" + 9 -> "hub" + else -> "class-${i.interfaceClass}" + } + Text( + " iface#${i.number} class=${i.interfaceClass} ($iLabel) sub=${i.interfaceSubclass} proto=${i.interfaceProtocol} ep=${i.endpointCount}", + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun AxisRow(label: String, x: Float, y: Float) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Text(label, modifier = Modifier.width(110.dp), style = MaterialTheme.typography.bodyMedium) + Text( + "x=${"%+.2f".format(x)} y=${"%+.2f".format(y)}", + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +private fun shortLabel(button: GamepadButton): String = when (button) { + GamepadButton.DPAD_UP -> "↑" + GamepadButton.DPAD_DOWN -> "↓" + GamepadButton.DPAD_LEFT -> "←" + GamepadButton.DPAD_RIGHT -> "→" + GamepadButton.FACE_TOP -> "△/Y/X" + GamepadButton.FACE_BOTTOM -> "✕/B/A" + GamepadButton.FACE_LEFT -> "□/X/Y" + GamepadButton.FACE_RIGHT -> "○/A/B" + GamepadButton.L1 -> "L1" + GamepadButton.R1 -> "R1" + GamepadButton.L2 -> "L2" + GamepadButton.R2 -> "R2" + GamepadButton.L3 -> "L3" + GamepadButton.R3 -> "R3" + GamepadButton.START -> "START" + GamepadButton.SELECT -> "SELECT" + GamepadButton.MODE -> "MODE" + GamepadButton.Z_BUTTON -> "Z" + GamepadButton.C_BUTTON -> "C" +} diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt index 59bbd97..6fa4ba4 100644 --- a/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt +++ b/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt @@ -1,5 +1,6 @@ package com.lazy.emulate.ui.screens.game +import android.app.Activity import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Handler @@ -7,6 +8,7 @@ import android.os.Looper import android.view.PixelCopy import android.view.SurfaceHolder import android.view.SurfaceView +import android.view.WindowManager import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -109,6 +111,17 @@ fun GameScreen( ControllerLayout.defaultForConsole(game.consoleType) } + // Keep the screen on while a game is on-screen. This also keeps the system out of the + // deeper sleep / power-saving states that aggravate USB OTG selective-suspend on + // Samsung devices, where the adapter drops every ~60-120 seconds otherwise. + DisposableEffect(Unit) { + val window = (context as? Activity)?.window + window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + onDispose { + window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + DisposableEffect(game.id) { val loaded = EmulationEngine.startGame(context, game.consoleType, game.romPath) if (loaded != null) { diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt index 5c5584b..3a81571 100644 --- a/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt @@ -54,7 +54,8 @@ fun SettingsScreen( controllerManager: ControllerManager, preferencesManager: PreferencesManager, buttonMappingManager: ButtonMappingManager, - onBack: () -> Unit + onBack: () -> Unit, + onTestController: () -> Unit = {} ) { val controllers by controllerManager.connectedControllers.collectAsState() val activeController by controllerManager.activeController.collectAsState() @@ -143,6 +144,38 @@ fun SettingsScreen( } } + Spacer(modifier = Modifier.height(12.dp)) + + // Test Controller — live tester with raw event log + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onTestController) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Gamepad, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + "Test Controller", + style = MaterialTheme.typography.bodyLarge + ) + Text( + "Live button tester and raw event log", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) // Touch overlay section diff --git a/app/src/main/res/xml/usb_device_filter.xml b/app/src/main/res/xml/usb_device_filter.xml new file mode 100644 index 0000000..d4231bd --- /dev/null +++ b/app/src/main/res/xml/usb_device_filter.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/docs/joycon-esp32-bridge.md b/docs/joycon-esp32-bridge.md new file mode 100644 index 0000000..fefb29e --- /dev/null +++ b/docs/joycon-esp32-bridge.md @@ -0,0 +1,377 @@ +# Joy-Con ESP32 Bridge — Design Notes + +Design for replacing the 8BitDo USB Wireless Adapter 2 with a DIY +ESP32-based Joy-Con bridge. Not yet implemented — this doc captures the +plan so it's ready to pick up when the hardware arrives. + +## Decisions + +- **Chosen architecture: Path A (fully wireless).** The bridge pairs with + the Joy-Con over BT Classic, then re-advertises itself to the phone as + a BLE HID gamepad. No cable between the phone and the bridge. The + bridge runs off its own battery (LiPo + charging circuit, or a small + USB power bank). +- **Chosen hardware: ESP32-WROOM-32 USB-C dev board** + ([Amazon.ca link](https://www.amazon.ca/ESP-WROOM-32-NodeMCU-Bluetooth-Development-Microcontroller/dp/B0CHBMFJBQ)). + Original ESP32 silicon (BT Classic + BLE, dual-mode radio), CP2102 + USB-serial for programming, USB-C for power + flashing. +- **Form factor plan:** 3D-printed case housing the ESP32 + a LiPo + battery + a small TP4056-style USB-C charge/protection board. Sits on + the couch next to the phone, pairs with the Joy-Con, connects to the + phone wirelessly. +- **Path B (USB serial) is the fallback.** If the dual-mode BT + coexistence on the ESP32 turns out to be too painful, we can fall back + to sending button state over the on-board CP2102's USB serial — + everything on the Joy-Con side is identical, and the app-side changes + for a USB-CDC reader are documented below. + +## Why + +The current "working" path uses an 8BitDo USB Wireless Adapter 2 in PS-Classic +mode, plugged into the Samsung Fold 6 via an OTG cable. It works, but has three +annoying problems: + +1. **It wedges periodically.** The adapter stops sending HID reports every + 60–120 seconds on this phone. Unplug + replug clears it. Likely root cause + is Samsung's aggressive USB selective-suspend / OTG power management + kicking the idle device off the bus. See the "dongle rabbit hole" notes + below for everything we tried. +2. **PS-Classic mode is fragile.** We picked it because it's the only mode + where Samsung's kernel `usbhid` actually binds the device as a real + gamepad. Direct Joy-Con Bluetooth, XInput mode, DInput mode, and macOS/DS4 + mode all fail for various reasons specific to Samsung's kernel + this + adapter's firmware. +3. **It's a cable dangling off a foldable phone.** Not great ergonomically. + +Doing our own Joy-Con-to-gamepad translation on an ESP32 lets us control both +ends of the conversation: we pair the Joy-Con to hardware we own, we do the +init handshake correctly, and we present the result to the phone as either a +standard BLE HID gamepad (no cable) or a USB-CDC serial stream (cable, but we +control the firmware so the selective-suspend problem goes away). + +## Why the Joy-Con is non-trivial + +Joy-Cons are Bluetooth **Classic** HID devices. They are NOT BLE. This means +any bridge MCU must have BT Classic on its radio. + +Once paired, they default to input report mode `0x3F`, which only emits a +small subset of buttons as a dumb HID "joystick". To get the full +button-mask + analog sticks + IMU + battery, you have to send a sequence of +subcommands: + +1. Read SPI flash calibration at `0x6020` (stick factory calibration) and + `0x8010` (stick user calibration) +2. `set_player_lights` (subcommand `0x30`) +3. `enable_imu` (subcommand `0x40`) if you want gyro/accel +4. **`set_input_report_mode` to `0x30`** (subcommand `0x03`) — this is the + important one; switches to the 12-byte input report that contains + everything you actually want + +Once the Joy-Con is in `0x30` mode, every input report is a fixed layout: + +``` +byte purpose + 0 0x30 (report id) + 1 timer (rolling counter) + 2 battery + connection info + 3 buttons right (Y, X, B, A, SR, SL, R, ZR) + 4 buttons shared (-, +, R-stick, L-stick, home, capture) + 5 buttons left (down, up, right, left, SR, SL, L, ZL) + 6-8 left stick (12-bit X + 12-bit Y, packed) + 9-11 right stick (same) + 12 vibrator ack + 13+ IMU samples (3 frames x 12 bytes) if IMU enabled +``` + +The full protocol is documented here: +- https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering +- https://github.com/DanielOgorchock/joycond (Linux userspace init daemon) +- Linux mainline `drivers/hid/hid-nintendo.c` (kernel driver that does the + same init sequence in C) + +Either joycond or hid-nintendo is a fine starting point for porting to C on +ESP32. + +## Hardware + +Joy-Cons use **Bluetooth Classic (BR/EDR)**, not BLE. This is the single +most important constraint on the hardware. Most modern MCU boards that +have "Bluetooth" actually only have BLE, which won't work. + +Only these chips have BT Classic: + +| Chip | BT Classic | BLE | Native USB | Fits? | +|----------------------------|:----------:|:---:|:----------:|:-----:| +| **ESP32** (D0WDQ6 / WROOM-32 / WROVER-32) | ✅ | ✅ | ❌ | ✅ **best default** | +| **Raspberry Pi Pico W** (CYW43439) | ✅ | ✅ | ✅ | ✅ also works | +| ESP32-S3 | ❌ | ✅ | ✅ | ❌ BLE only (common mistake) | +| ESP32-S2 | ❌ | ❌ | ✅ | ❌ no BT at all | +| ESP32-C3 | ❌ | ✅ | ❌ | ❌ BLE only | +| ESP32-C6 | ❌ | ✅ | ❌ | ❌ BLE + 802.15.4 only | +| ESP32-H2 | ❌ | ✅ | ❌ | ❌ BLE + 802.15.4 only | + +**⚠️ Do not buy ESP32-S3 for this project** even though it looks like the +obvious upgrade over the original ESP32. Espressif confirmed in their own +datasheet that ESP32-S3 only supports Bluetooth 5 LE — no BR/EDR — and +you cannot connect a Joy-Con to it over Bluetooth. The Bluepad32 FAQ +makes this explicit: *"controllers like Switch, Wii, DualSense, DualShock, +etc. only talk BR/EDR... you cannot use an ESP32-S3 as a Bluetooth HID +host to connect to Joy-Con controllers."* + +**Recommended: original ESP32-WROOM-32 dev board** — any of the Amazon +"DOIT DEVKIT V1", "HiLetgo", "ELEGOO", "DIYmall" variants. They're all +the same chip, usually with a CP2102 or CH340 USB-to-serial chip on +board so you get programming + serial communication over a single USB +port. $8–12 Canadian. BT Classic is built into the ESP32-D0WDQ6 chip. +No native USB-OTG, so in Path B the chip talks to the phone over the +on-board USB-serial bridge. + +**Also good: Raspberry Pi Pico W** — the CYW43439 WiFi/BT combo chip +on the Pico W supports BT Classic, and the RP2040 has native USB. If +you prefer C SDK + CMake to Arduino, Pico W is a nicer dev experience, +and the Bluepad32 library supports it as a first-class target alongside +the original ESP32. + +**ESP32-WROVER-32** is the same silicon as WROOM-32 plus an extra PSRAM +chip. Works identically for this project but costs a bit more for PSRAM +we don't need. Fine if that's what you already have. + +## Two architectures + +### Path A — fully wireless (BT Classic host + BLE peripheral) + +``` +[Joy-Con] --BT Classic HID--> [ESP32-S3] --BLE HID--> [Phone] +``` + +The ESP32 runs two radio profiles at once on its dual-mode stack: + +- **BT Classic HID host** (ESP-IDF `esp_hidh` API) — pairs with the Joy-Con, + runs the init subcommand sequence, subscribes to `0x30` input reports +- **BLE HID device** (ESP-IDF `esp_hids` API, or the `ESP32-BLE-Gamepad` + Arduino library) — advertises itself to the phone as a standard BLE + gamepad with a generic HID descriptor + +The Android side needs **no code changes**. Samsung's kernel binds BLE HID +gamepads natively (BLE HID is a completely different code path from the +USB HID mess we've been fighting), the existing `InputDevice`/ +`dispatchKeyEvent` path picks up button events, and our +`ControllerManager` routes them into the emulator the same way it does +for the 8BitDo today. + +**Pros** +- No cable. Charge the ESP32 off a tiny LiPo or a power brick, use it as a + wireless dongle sitting next to the phone. +- Nothing to change in the app. +- Once it works, it works the same for every game on every emulator we + already support. + +**Cons** +- Running BT Classic host + BLE peripheral simultaneously on the same radio + is non-trivial. ESP-IDF supports dual mode but the profiles have to share + a single controller — expect to spend time in `menuconfig` and the + Bluetooth controller's coexistence settings. +- Bigger firmware, more things that can go wrong during bring-up. + +### Path B — wired hybrid (BT Classic host + USB-CDC serial) + +``` +[Joy-Con] --BT Classic HID--> [ESP32-S3] --USB CDC serial--> [Phone] +``` + +Same Joy-Con side. Different phone side: instead of re-broadcasting over +BLE, the ESP32 streams button state as short binary frames over USB CDC. +The phone reads them via `usb-serial-for-android` or directly via the +`UsbManager` + `bulkTransfer` APIs we already use. + +**Pros** +- Simpler firmware — no dual-mode radio, no BLE HID descriptor, no BLE + pairing dance. Get it working in a weekend. +- We own the wire protocol, so it's trivially debuggable. Dump the serial + bytes in Serial Monitor and you see exactly what the Joy-Con is sending. +- **Selective-suspend goes away** because we control the firmware on + both sides. Send a 60-Hz heartbeat frame even when no buttons are held + and the USB bus stays active. The ESP32 can also be externally + powered, so the phone's bus-power policy becomes irrelevant. +- Direct fit for the existing `ControllerManager.dispatchButton` plumbing. + +**Cons** +- Still a cable between the ESP32 and the phone (we're swapping the 8BitDo + OTG cable for an ESP32 OTG cable — ergonomically the same). +- Requires a small addition to the app to read USB serial and parse the + wire protocol. + +Recommended order of attack: **build Path B first**. The hard part (Joy-Con +BT Classic host + init sequence + `0x30` report parsing) is identical in +both paths, so Path B gets you to a working gamepad fastest and validates +the Joy-Con side. Once Path B is rock-solid, swap the output stage for BLE +HID and you have Path A for free (modulo the dual-mode coexistence tuning). + +## Wire protocol sketch (Path B) + +Keep it tiny, keep it fixed-size, keep it self-synchronizing. + +``` +byte 0 0xA5 sync / frame header +byte 1 seq rolling sequence counter, wraps at 255 +byte 2 btns_lo (A, B, X, Y, L1, R1, L2, R2) +byte 3 btns_hi (Start, Select, L3, R3, Home, Capture, reserved, reserved) +byte 4 dpad packed: 4 bits for hat direction (0-7 clockwise from N, 8=none), + 4 bits for Joy-Con-specific buttons (SR/SL etc) +byte 5 lx signed int8, -127..127 (already deadzoned + calibrated on ESP32) +byte 6 ly signed int8 +byte 7 rx signed int8 +byte 8 ry signed int8 +byte 9 flags bit 0: battery low, bit 1: charging, bit 2: imu_valid, bits 3-7: reserved +byte 10 crc8 CRC-8 over bytes 0..9 (or just XOR checksum if we're lazy) +``` + +11 bytes per frame, 60 Hz = 660 bytes/sec. USB CDC at 115200+ baud handles +this with orders of magnitude to spare. Even ESP32's default 921600 baud is +fine for 240 Hz polling. + +If later we want motion / gyro for games that use it, we append another ~12 +bytes and bump a version byte in the header. Forward-compatible. + +## App-side changes (Path B) + +All contained in `ControllerManager.kt` and a new helper file: + +1. **New dependency** on [usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android) + (tiny, ~50 KB; supports CH340, CP210x, FTDI, and CDC-ACM, which covers + every ESP32 dev board). +2. **Add ESP32-S3 native USB VID/PID** (`0x303A / 0x1001`) and CP2102 + (`0x10C4 / 0xEA60`) to `targetVidPids` in `ControllerManager`. +3. **In `onUsbDeviceAttached`**, after the existing HID-class short-circuit, + check whether the device matches one of the ESP32 VID/PIDs. If so, open + it as a USB serial port instead of going through the HID path, start a + reader thread, and parse incoming `[0xA5, seq, btns_lo, btns_hi, dpad, + lx, ly, rx, ry, flags, crc]` frames. +4. **For each frame**, diff against the previous frame and call + `dispatchButton(...)` for any changed button bits, plus `onAnalogEvent(...)` + for stick updates. Existing plumbing carries it into the emulator core. +5. **Heartbeat / liveness**: track the frame sequence counter, and if we + don't see a frame for 500 ms, log a warning and mark the controller as + disconnected so the test screen reflects it. + +Approximate LOC: ~150 lines in ControllerManager + 50 lines for the wire +protocol parser. No changes needed anywhere else in the app. + +## Firmware-side notes + +### Bluepad32 changes the math + +There's a project called **[Bluepad32](https://bluepad32.readthedocs.io/)** +that already implements BT Classic HID host for original ESP32 and Pico W, +with **explicit first-class Joy-Con support** alongside DualShock 3/4/5, +Switch Pro Controller, Wii Remote, Xbox, and generic HID gamepads. It does +the init subcommand dance, SPI-flash calibration reads, and 0x30 input +report parsing for you. MIT licensed. + +This means the Joy-Con side of this project essentially does not need to +be written — it's library-level `#include`. Our firmware job shrinks to: + +1. Initialize Bluepad32 and register a gamepad callback. +2. Format the callback's button/stick state into our wire protocol. +3. Send it out the output stage (USB serial for Path B, BLE HID for Path A). + +The Joy-Con init quirks, reconnect handling, stick calibration, and BT +pairing UX are already solved. + +### Starting points + +- **[Bluepad32](https://github.com/ricardoquesada/bluepad32)** — the main + library. Has ESP-IDF and Arduino examples. The `controllers/` demo example + pairs gamepads and prints button state to serial; adapting it to our + wire format is ~50 lines. +- For **Path A** (BLE output): glue the Bluepad32 input side to + [`ESP32-BLE-Gamepad`](https://github.com/lemmingDev/ESP32-BLE-Gamepad) — + working BLE HID gamepad profile in ~30 lines of Arduino. Caveat: BLE + peripheral coexisting with BT Classic host on one chip is tricky. +- For **Path B** (USB serial): just `Serial.write()` the frame from the + Bluepad32 callback. Trivial. +- If you'd rather not use Bluepad32 and write the protocol yourself (for + learning or licensing reasons), reference joycond (C++, Apache 2.0) or + the Linux kernel `drivers/hid/hid-nintendo.c` (C, GPL). + +### Prior art + +- https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering — full + protocol docs +- https://github.com/DanielOgorchock/joycond — Linux userspace init daemon +- https://github.com/pipe01/joycon-rs — Rust Joy-Con protocol library +- Various ESP32-based Nintendo Switch Pro Controller *emulators* also + exist — they go the other direction (pretend to be a Pro Controller + talking to a Switch console) but share the same protocol knowledge. + +### Estimated effort (with Bluepad32) + +- **Path B (serial out)**: a weekend to get working, another weekend to + harden against reconnect/pair-loss edge cases. Total ~10–20 hobby hours. +- **Path A (BLE out)**: same Path B starting point plus 1–2 weeks of + wrestling with BT Classic + BLE dual-mode coexistence on one chip. + Bluepad32 itself only targets single-mode BT Classic, so you'd be + extending it or running the BLE side in parallel via raw ESP-IDF calls. + Harder to estimate. + +## The dongle rabbit hole (what we already tried and why it didn't work) + +For anyone picking this up later, here's the short history so you don't +re-run the same experiments: + +- **Direct Joy-Con over Bluetooth to Samsung Fold 6.** Pairs fine, kernel + creates two evdev nodes (main + IMU), IMU streams MSC_TIMESTAMP so the + link is alive, but the main node has only `BTN_TL`/`BTN_TL2` in its key + capability set (no face buttons, no dpad, no stick) and even those fire + zero events. Samsung's `hid-nintendo` driver parks the node waiting for a + userspace init handshake that nothing on the system provides, and shell + can't `chmod` `/sys/bus/hid/devices/.../` to kick it. +- **8BitDo adapter, XInput mode.** Enumerates as an Xbox 360 wired + controller (vendor-spec class 0xFF / subclass 0x5D). Android has no + XInput driver in its kernel. Adapter endlessly cycles modes because it + can't complete the Xbox 360 handshake with the phone. +- **8BitDo adapter, DInput mode.** Enumerates as a clean HID-class device, + but Samsung's kernel `usbhid` refuses to bind it for unknown reasons + (verified by uninstalling the app entirely and replugging — no + `/dev/input/event*` is ever created). Not fixable from userspace. +- **8BitDo adapter, macOS mode.** Enumerates as a Sony DualShock 4 + (`054C:05C4`). Same failure as DInput — Samsung's kernel doesn't bind + it. Despite Android having native DS4 support. +- **8BitDo adapter, PS-Classic mode.** Enumerates as a "Sony Interactive + Entertainment Controller" (`054C:0CDA`). Samsung's kernel actually binds + this one as a real gamepad. **This is what we ship today.** Downside: the + adapter wedges every 60–120 seconds and needs an unplug+replug to + recover, presumably from OTG selective-suspend. + +Things that partially work or haven't been fully explored: +- Plugging the adapter into a powered USB hub between the phone and the + adapter helps somewhat with the wedging but does not fully fix it. +- Disabling Samsung battery optimization for the app does not fix the + wedging. +- Holding `FLAG_KEEP_SCREEN_ON` on the game window helps (the system + stays out of the deep-sleep regime that aggravates selective-suspend) + but again doesn't fully fix it. + +All three workarounds stack with the ESP32 bridge idea if we go Path B — +but Path A (BLE) makes them all irrelevant because there's no USB bus to +suspend in the first place. + +## Open questions to resolve during bring-up + +1. Does the Samsung Fold 6 kernel actually bind ESP32-S3 native USB-CDC + as a `/dev/bus/usb/` device visible to our app, or does it do something + weird to USB-CDC too? Should be fine — CDC-ACM is boring and + well-supported — but verify before writing firmware. +2. What's the latency floor from Joy-Con button press to emulator core? + Joy-Con → BT Classic (~4-8 ms) + ESP32 parse (<1 ms) + USB serial + (<1 ms) + app dispatch (<1 ms) should come in under 15 ms, comfortably + below the ~16 ms/frame budget at 60 Hz. Worth measuring once hardware + exists. +3. Left+Right Joy-Con combined as one "Pro Controller" style pad, or + single-Joy-Con sideways mode? Single-Joy-Con is simpler for NES/SNES + and matches the NES Joy-Con we have. Pairing two Joy-Cons to the same + ESP32 is a separate BT Classic multi-device problem that we can punt + on. +4. Rumble — do we care? Joy-Con rumble is HD Rumble, which is a nightmare + even by Joy-Con standards. Probably punt; the emulated consoles we + support didn't have rumble anyway. diff --git a/docs/joycon-investigation-history.md b/docs/joycon-investigation-history.md new file mode 100644 index 0000000..11dfd41 --- /dev/null +++ b/docs/joycon-investigation-history.md @@ -0,0 +1,178 @@ +# Handoff: Joy-Con / 8BitDo Adapter on Samsung Z Fold 6 + +> **⚠️ Status: deprecated / historical.** +> +> The questions this document was handing off ("how do we get a Joy-Con +> working on the Samsung Fold 6 via the 8BitDo adapter?") were ultimately +> **solved** — the working path was to put the adapter into PS-Classic +> mode (Select + D-Pad Down for 3 seconds), which is the only mode where +> Samsung's kernel `usbhid` actually binds the device as a real gamepad. +> That solution ships today in `ControllerManager.kt` and works, but the +> adapter wedges every 60–120 seconds due to Samsung's OTG selective +> suspend. See the commit log on `feature/joycon` for the full fix set. +> +> Going forward, the 8BitDo-on-OTG path is **being replaced** by a DIY +> ESP32-WROOM-32 bridge that pairs with the Joy-Con over Bluetooth +> Classic and re-advertises itself to the phone as a BLE HID gamepad — +> no cables, no selective-suspend quirks. That design lives in +> [`joycon-esp32-bridge.md`](./joycon-esp32-bridge.md) and is the active +> work going forward. +> +> This file is kept purely for archaeology — it documents everything we +> tried on the adapter path, in the order we tried it, so future-me +> doesn't re-run any of the same experiments. **Do not use it as a +> current how-to.** + +## Goal +Enable gamepad input from a Nintendo NES Controller (R) Joy-Con for the emulator app in `/Users/matt/code/android/emulate` (package `com.lazy.emulate`, branch `feature/joycon`), running on a Samsung Galaxy Z Fold 6 (model `SM-F966W`) with Android 16. + +## Hardware in play +- **Phone**: Samsung SM-F966W, Android 16, build `25D125`-era Samsung kernel, DWC3 USB host controller (`/sys/class/udc/a600000.dwc3`) +- **Controller**: Nintendo "NES Controller (R)" (Switch Joy-Con (R) variant). Bluetooth name "NES Controller (R)", USB vid/pid `0x057E/0x2007` +- **Adapter**: 8BitDo USB Wireless Adapter 2 (the Joy-Con-compatible model, officially documents Android support at `support.8bitdo.com/Manual/USB-Adapter-2/switch-joycon-others.html`). **Firmware updated to latest** via 8BitDo's dedicated firmware updater (not Ultimate Software V2) during this session +- **Hub**: Anker USB-C hub with PD-in, ethernet, HDMI, 1× USB-C, 2× USB-A. Powered via PD, adapter plugged into a USB-A port +- **Mac** (MacBook on Darwin 25.3.0) used for cross-platform comparison and firmware flashing + +## Paths tried and what we learned, in order + +### 1. Direct Joy-Con over Bluetooth +- Joy-Con pairs to phone fine; Samsung's kernel includes `hid-nintendo` driver +- `getevent -pl` shows TWO evdev nodes created: `event16 "Nintendo Switch Right Joy-Con"` (with only `BTN_TL`, `BTN_TL2` in its key capability set — no face buttons, no dpad, no stick) and `event17 "Nintendo Switch Right Joy-Con IMU"` (accelerometer + gyro) +- IMU node actively streams `MSC_TIMESTAMP` events — controller is alive and communicating with kernel +- Main event16 node: **zero events fire**, even for SL/SR which its capability set claims. Confirmed via `adb shell getevent -l /dev/input/event16` while user pressed every button +- Samsung OneUI has a per-device "Use as input device" toggle in BT settings which we confirmed was ON — didn't change behavior +- **Diagnosis**: Samsung kernel has `hid-nintendo` driver but no userspace equivalent of Linux `joycond` to finish the init handshake and enable single-controller sideways mode. Driver parks the main node with only the rail buttons exposed (and even those don't fire). `/sys/bus/hid/devices/0005:057E:2007.0007/` is Permission denied to `adb shell` (SELinux `u:r:shell:s0`) even though shell is in the `input` group. No way to force mode from userspace without root. + +### 2. Joy-Con via 8BitDo Wireless Adapter 2 — native Android HID path +The adapter is documented as Android-compatible. Plug it in, pair Joy-Con, expect plain HID gamepad. + +What actually happened (observed multiple times, multiple firmware versions): +- Adapter cycles between USB descriptors every ~400ms: + - `vid=0x2DC8 pid=0x3106 name="8BitDo Pro 2 Wired Controller"` (what 8BitDo calls D-input) + - `vid=0x045E pid=0x028E name="Controller" (Microsoft X-Box 360 pad)` (X-input) + - Occasionally also `vid=0x2DC8 pid=0x3107 name="IDLE"` (no paired controller state) +- Both modes have `class=255 (vendor-spec)` and `interfaceClass=255 subclass=0x5D (93) protocol=0x01` — **this is the textbook Xbox 360 wired Xinput descriptor**, not a HID gamepad class. Android has no Xinput driver in its kernel/framework, so `usbhid` never binds and nothing ever reaches the `InputDevice` / `KeyEvent` layer despite both modes showing up in `InputDevice.getDeviceIds()` with `SOURCE_GAMEPAD | SOURCE_JOYSTICK` bits set (presumably Android created a "Microsoft X-Box 360 pad" generic profile from the vid/pid match, but no bind because no driver) +- The adapter auto-cycles because its firmware is probing for "which host am I plugged into" and retrying on timeout — it never gets the Xinput handshake it expects from Android +- The Joy-Con side *does* pair successfully (solid blue on adapter, solid player 1 LED on Joy-Con). Pairing doesn't stop the USB-side mode cycling +- The adapter also has a **"green LED state"** which we initially thought was "Switch mode" but confirmed via macOS ioreg is actually **bootloader mode**: `vid=0x2DC8 pid=0x3208 name="BOOT"`. Enterable by holding the pair button during plug-in. In this state the adapter stably enumerates but can't pair any controllers (it's waiting to be flashed) +- **Mac cross-check**: Same hardware on macOS enumerates stably as `vid=0x054C pid=0x05C4 name="8BitDo Receiver"` (i.e. impersonating a Sony DualShock 4). macOS IOHIDFamily binds natively. Joy-Con buttons presumably work. We never confirmed button data end-to-end on the Mac, only that USB enumeration was stable and single-device +- **Firmware update**: user's adapter shipped with 2023 firmware; we flashed to latest 2025/2026 firmware via the 8BitDo firmware updater. Cycling behavior on Android is identical before and after +- **Ultimate Software V2** (8BitDo's config tool at `app.8bitdo.com/Ultimate-Software-V2/`) does not detect this adapter on macOS — only the older dedicated firmware updater does + +### 3. USB-claim experiment (taking over the device from userspace) +Theory: if we claim the USB interface via `UsbDeviceConnection.claimInterface(force=true)`, we detach kernel usbhid, and the adapter might stop cycling because it's finally talking to "a host". + +Implementation: +- `res/xml/usb_device_filter.xml` lists the four vid/pids we've seen (`0x2DC8/0x3106`, `0x2DC8/0x3107`, `0x045E/0x028E`, `0x054C/0x05C4`) +- `AndroidManifest.xml` has `` for `USB_DEVICE_ATTACHED` + `` pointing to the filter, with `launchMode="singleTop"` on `MainActivity` +- `MainActivity.usbAttachReceiver` is a dynamic `BroadcastReceiver` registered in `onResume` for `ACTION_USB_DEVICE_ATTACHED`; unregistered in `onPause`. Also `onNewIntent → handleUsbAttachIntent` for manifest-delivered intents +- `ControllerManager.onUsbDeviceAttached(device)` filters to target vid/pids, requests permission via `PendingIntent`/`usbPermissionReceiver`, calls `openDevice()` + `claimInterface(force=true)` on interface #0 only (class=0xFF sub=0x5D proto=0x01 — the main input interface) + +Results (observed on multiple runs): +- **Claim succeeds**: log shows `USB claimInterface #0 class=255 sub=93 proto=1 → OK (kernel detached)` and `USB holding claim on 1 interface.` +- **On the first run without a reader thread, the cycling stopped**. We saw one successful X-input claim at `15:32:40.548` followed by no further attach events for the target vid/pids. Only a single `ignoring non-target USB device v=1406 p=8201` (which is Joy-Con Bluetooth HID reappearing via Samsung's UHID pipe). **This is the one promising data point.** Reproducibility unknown — every subsequent attempt thrashed +- **Adding the Xinput reader thread broke everything**: `bulkTransfer(endpoint=0x81, buf, 32, 200ms)` returns `-1` almost instantly (~10 ms per call, ~100 consecutive errors in 1 second). Reader exits via the "101 consecutive errors" bailout with `packets=0`. Never received a single report. Endpoint address 0x81 is correct (IN bit + endpoint 1, standard Xbox 360 layout). Interrupt vs bulk transfer type: Android `UsbDeviceConnection` has no `interruptTransfer` — `bulkTransfer` handles both +- **Hypothesis for zero reads**: Xbox 360 controllers (real and emulated) sometimes require an LED init command (`0x01 0x03 0xNN` on the OUT endpoint) before they emit input reports. The adapter may additionally be waiting for the Xbox 360 security challenge-response (interface #3 in X-input mode is proto=19, 0 endpoints — that's the "security" interface the real Xbox console handshakes). We did not implement either +- **Activity lifecycle interaction**: with the manifest intent filter + `singleTop`, every `USB_DEVICE_ATTACHED` delivered via the filter seems to tear down and recreate `MainActivity` on Samsung OneUI, calling `onDestroy → releaseClaim → hasActiveClaim=false`. Each recreate starts a new `ControllerManager`, a new claim, a new reader thread. Old reader threads cannot be joined cleanly because the old `UsbDeviceConnection` from the destroyed activity is stuck in `bulkTransfer`. **Thread leak → OOM** at `VmSize ~19.9 GB` (hundreds of 8 MB thread stacks). We hit this OOM multiple times +- **Samsung DWC3 host controller physically wedges**: after enough claim/release thrashing, `/sys/bus/usb/devices/` goes completely empty (not even root hubs visible), `IsHostConnected :false`, `mIsHostConnected :false`. Only a full phone reboot recovers. This happened twice and is reproducible with enough cycling + +### 4. Other things tried along the way +- Switching the adapter through every button-combo mode the user could find (blue flashing normal, green bootloader, holding pair during plug-in, etc) +- Plugging directly into phone with USB-C OTG cable (no hub) — inconsistent host mode detection; Samsung `SettingBlockUsbLock :1` flag was noted in dumpsys output but seemed to only matter when the phone was locked +- Plugging into the Mac to verify adapter isn't broken — it's fine on Mac +- Ultimate Software V2 / firmware updater on Mac — firmware updated successfully; Ultimate Software V2 never detected the adapter in any state +- Moving to wireless ADB once OTG occupied the phone's USB-C port + +## Current state of the code (branch `feature/joycon`, uncommitted) + +### Modified files +| File | What's in it | +|---|---| +| `app/src/main/AndroidManifest.xml` | `launchMode="singleTop"`, `USB_DEVICE_ATTACHED` intent filter + meta-data, `uses-feature android.hardware.usb.host` | +| `app/src/main/res/xml/usb_device_filter.xml` | **NEW** — four target vid/pids | +| `app/src/main/java/com/lazy/emulate/MainActivity.kt` | Dynamic `usbAttachReceiver`, `handleUsbAttachIntent`, `onNewIntent`, `releaseClaim()` call in `onDestroy`, `dispatchKeyEvent`/`dispatchGenericMotionEvent` override (replaces `onKeyDown`/`onKeyUp`) | +| `app/src/main/java/com/lazy/emulate/input/ControllerManager.kt` | Huge changes — see below | +| `app/src/main/java/com/lazy/emulate/input/GamepadButton.kt` | Unchanged | +| `app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt` | New `ControllerTest` route | +| `app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt` | Wires `ControllerTest` route | +| `app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerTestScreen.kt` | **NEW** — live button grid, axis readout, USB device list, scrolling raw event log | +| `app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt` | New "Test Controller" card that navigates to the test screen | + +### `ControllerManager.kt` inventory +- `VENDOR_NINTENDO = 0x057E` — unused now +- `MAX_EVENT_LOG_LINES = 300` +- `AnalogSnapshot` data class +- `UsbDeviceInfo`, `UsbInterfaceInfo` data classes +- StateFlows: `connectedControllers`, `activeController`, `rawEventLog`, `pressedButtons`, `analogSnapshot`, `usbDevices` +- `usbPermissionAction`, `usbPermissionReceiver`, registered/unregistered in `start`/`stop` +- `ClaimedDevice` data class + `claimedDevice` field +- `hasActiveClaim: Boolean` — intended to prevent thread spam, effectively defeated by Activity recreation +- `targetVidPids: Set>` — the four target vid/pids +- `lastAttachTimeByKey`, `attachThrottleMs = 500L` — per-vid/pid debounce +- `permissionRequestedFor: MutableSet` — one-shot permission request per key +- `xinputReaderThread: Thread?`, `xinputReaderStop: AtomicBoolean` +- `xinputPrevButtons`, `xinputPrevL2`, `xinputPrevR2` +- Xinput bit constants + `xinputBitToButton` mapping list +- `onUsbDeviceAttached(device)` — target filter → `hasActiveClaim` check → already-claimed check → throttle → permission request → `openDevice` → `claimInterface(iface, force=true)` on first iface matching `0xFF/0x5D/0x01` → `startXinputReader` +- `tryClaimAlreadyConnectedUsb()` — called from `onResume` sweep +- `releaseClaim()` — sets stop flag, releases interface, closes connection, joins reader thread, clears state +- `startXinputReader(connection, interfaces)` — finds IN endpoint, spawns daemon thread, calls `bulkTransfer` in a loop, parses Xinput reports, exits after 100 consecutive errors +- `parseXinputReport(buf)` — 20-byte Xinput packet parser, dispatches buttons + analog sticks with Y-negated to match Android axis convention +- `dispatchButton(button, isDown)` — updates `_pressedButtons` + invokes `onButtonEvent`. Used by both the Android key path and the Xinput reader path +- `refreshUsbDevices()` — iterates `usbManager.deviceList`, builds `UsbDeviceInfo` snapshots +- `appendEventLog(line)` — ring buffer + mirrors to `Log.d(TAG, "LOG: $line")` so everything visible in `adb logcat -s ControllerManager:D` +- `isGameController(device)` — now also accepts any device with `VENDOR_NINTENDO` vendor id as a fallback (unused in practice; can be reverted) +- `handleKeyEvent`/`handleMotionEvent` — have extensive debug `Log.d` calls that dump every event including non-gamepad devices + +### Compose UI +`ControllerTestScreen.kt` shows, top to bottom: **Detected Controllers** (from `InputDevice`), **USB Devices** (from `UsbManager`, with rescan button), **Pressed Buttons** (live chip grid colored per `_pressedButtons`), **Axes** (live analog values), **Raw Events** (scrolling `LazyColumn` with auto-scroll and a clear-log action in the top bar). The USB section highlights `class=3 (HID)` entries in green and `class=0xFF (VENDOR-SPEC)` in red (labelled "NO HID"). + +## Evidence for the promising path +On one run (`15:32:39 – 15:32:46`, logcat available by running `adb logcat -d -s ControllerManager:D` soon after reproducing), with a fresh boot and the adapter in blue mode + Joy-Con paired: + +``` +15:32:39.706 USB ATTACH '8BitDo Receiver' v=0x2dc8 p=0x3106 +15:32:39.708 USB claimInterface #0 class=255 sub=93 proto=1 → OK (kernel detached) +15:32:39.709 USB holding claim on 1 interface(s). +15:32:40.548 USB ATTACH 'Controller' v=0x045e p=0x028e +15:32:40.551 USB claimInterface #0 sub=93 proto=1 → OK +15:32:40.551 USB claimInterface #1 sub=93 proto=3 → OK +15:32:40.551 USB claimInterface #2 sub=93 proto=2 → OK +15:32:40.551 USB claimInterface #3 sub=253 proto=19 → OK +15:32:40.551 USB holding claim on 4 interface(s). +15:32:42.724 ignoring non-target USB device v=1406 p=8201 +(silence — no more attach events for target vid/pids) +``` + +This was the run **without the Xinput reader thread**. Cycling stopped after we claimed, for the remaining duration of the run. Adding the reader thread in subsequent runs reintroduced instability. + +## Open questions / things another agent might investigate +1. **Does the adapter need a host-side init command before it emits Xinput reports?** The standard xpad driver on Linux sends a 3-byte LED init `0x01 0x03 0xNN` on the interrupt OUT endpoint. Newer 8BitDo adapter firmware may need different init. If yes, what bytes? +2. **Is Xbox 360 security handshake required?** Interface #3 (proto=19, 0 endpoints) is the security/challenge interface. If the adapter demands `0xC1/0x01` and `0x41/0xA9` control transfers with valid responses, we'd need a reverse-engineered response generator +3. **Is `bulkTransfer` returning -1 immediately because the endpoint is halted, or because of a driver-state bug from the force-detach?** Could try `UsbRequest` async API instead. Could try issuing `connection.controlTransfer()` to send `CLEAR_FEATURE(ENDPOINT_HALT)` first +4. **Why does `MainActivity` appear to recreate on every `USB_DEVICE_ATTACHED` delivery despite `launchMode="singleTop"`?** Samsung OneUI might override. Could test: remove the manifest intent filter and rely purely on the dynamic receiver while the app is foregrounded; accept that the adapter must be plugged in AFTER the app is running. Or: move the USB handling out of the Activity entirely, into a `Service` so it survives recreation +5. **Is there a way to stop Samsung's kernel from ever binding to these vid/pids in the first place?** `UsbManager.requestPermission()` with "use by default" stores a persistent preference; if checked once, future attaches should route silently. Whether Samsung honors it is unclear +6. **Is the `hid-nintendo` kernel driver path actually fixable via some sysfs write that shell can reach via a different SELinux domain?** We confirmed `u:r:shell:s0` is denied on `/sys/bus/hid/devices/0005:057E:2007.0007/`. Maybe there's a different path +7. **Does the macOS-mode firmware (DS4 emulation, `0x054C/0x05C4`) ever appear on Android?** We only ever saw it on the Mac. If it were to appear on Android, Android has native DS4 support and it would Just Work — but the adapter's auto-detect never picks DS4 mode on this phone. Unclear why + +## Reboot state +When the user hands off, the Samsung phone's USB host controller may still be wedged from the last session (`/sys/bus/usb/devices/` was empty when checked). A phone reboot is required to recover USB host mode before further testing. The adb connection is currently wireless (`adb connect adb-RFCY71JW54X-E9025V._adb-tls-connect._tcp`); after reboot that may need re-pairing via Developer Options → Wireless debugging. + +## Files to read first for the next agent +1. `app/src/main/java/com/lazy/emulate/input/ControllerManager.kt` — the USB claim + Xinput reader code, ~600 lines +2. `app/src/main/java/com/lazy/emulate/MainActivity.kt` — the receiver + lifecycle +3. `app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerTestScreen.kt` — the debug UI +4. `app/src/main/AndroidManifest.xml` + `res/xml/usb_device_filter.xml` — the filter setup +5. `app/src/main/java/com/lazy/emulate/input/GamepadButton.kt` — the app's canonical button enum + +## To reproduce the test quickly +```bash +cd /Users/matt/code/android/emulate +./gradlew :app:installDebug +adb shell am start -n com.lazy.emulate/.MainActivity +# In app: Settings → Test Controller +# Plug in adapter (blue mode, Joy-Con paired to it) +adb logcat -s ControllerManager:D +``` + +Good luck to whoever picks this up.