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.