Files
emulate/docs/joycon-investigation-history.md
Matt Hills d496902e57 Move JOYCON_HANDOFF.md into docs/ and mark it historical
The handoff doc was the starting point for this session's controller
work — a list of open questions about getting a Joy-Con working on the
Samsung Fold 6 via the 8BitDo adapter. Those questions have been
answered (the working path is PS-Classic mode) and the adapter approach
is being replaced entirely by a DIY ESP32 bridge. Move the doc into
docs/ so it's not sitting at the repo root, rename it to reflect that
it's archaeology rather than an active handoff, and add a banner at
the top pointing at joycon-esp32-bridge.md for the current plan.
2026-04-11 19:47:03 -04:00

19 KiB
Raw Permalink Blame History

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 60120 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 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=0x01this 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 <intent-filter> for USB_DEVICE_ATTACHED + <meta-data> 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 interruptTransferbulkTransfer 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<Pair<Int, Int>> — the four target vid/pids
  • lastAttachTimeByKey, attachThrottleMs = 500L — per-vid/pid debounce
  • permissionRequestedFor: MutableSet<String> — 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 → openDeviceclaimInterface(iface, force=true) on first iface matching 0xFF/0x5D/0x01startXinputReader
  • 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

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.