From b3122aece012ad2bdb0ebcfa742be903bb5fed83 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 20 Oct 2026 16:53:37 -0400 Subject: [PATCH] fix: Rewired games see DroidDeck's pads (udev listing, dlsym-resolved libc calls) (#1222) --- app/src/main/cpp/fakeinput_steam.cpp | 91 +++++++++ .../com/droiddeck/launcher/SessionActivity.kt | 1 + .../launcher/session/InputSysfsComponent.kt | 179 ++++++++++++++++++ .../launcher/session/SessionService.kt | 11 ++ .../session/InputSysfsComponentTest.kt | 57 ++++++ 5 files changed, 339 insertions(+) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/InputSysfsComponent.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/InputSysfsComponentTest.kt diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 486b72076..a8c94d2ff 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -21,6 +21,9 @@ #include #include +#ifdef __GLIBC__ +#include +#endif #include #include #include @@ -4103,4 +4106,92 @@ EXPORT int __fxstat64(int version, int fd, struct stat64 *buf) { if (!real) return fstat64(fd, buf); return fake_stat_fd(fd, buf, [version](int f, struct stat64 *b) { return real(version, f, b); }); } + +#if defined(__x86_64__) || defined(__i386__) +using dlsym_fn = void *(*)(void *, const char *); + +static const char *const interposed_names[] = { + "open", "open64", "openat", "openat64", "close", "read", "write", "writev", "ioctl", "poll", + "ppoll", "select", "stat", "stat64", "fstat", "fstat64", "__xstat", "__xstat64", "__fxstat", + "__fxstat64", "statx", "statfs", "statfs64", "fstatfs", "fstatfs64", "access", "faccessat", + "opendir", "scandir", "inotify_add_watch", +}; + +static dlsym_fn next_dlsym() { + static const dlsym_fn fn = [] { +#ifdef __x86_64__ + const char *base = "GLIBC_2.2.5"; +#else + const char *base = "GLIBC_2.0"; +#endif + for (const char *version : {base, "GLIBC_2.34"}) { + if (auto found = reinterpret_cast(dlvsym(RTLD_NEXT, "dlsym", version))) return found; + } + return dlsym_fn{}; + }(); + return fn; +} + +static void *global_dlsym(void *, const char *name) { return next_dlsym()(RTLD_DEFAULT, name); } + +static void *missing_dlsym(void *, const char *) { return nullptr; } + +static bool interposed(const char *name) { + for (const char *interposed_name : interposed_names) { + if (strcmp(interposed_name, name) == 0) return true; + } + return false; +} + +extern "C" __attribute__((visibility("hidden"), used)) void *fakeinput_dlsym_target(void *handle, const char *name) { + dlsym_fn next = next_dlsym(); + if (!next) return reinterpret_cast(missing_dlsym); + if (handle == RTLD_DEFAULT || handle == RTLD_NEXT || name == nullptr) return reinterpret_cast(next); + static void *const libc = dlopen(LIBC_SO, RTLD_LAZY | RTLD_NOLOAD); + if (handle != libc || !interposed(name)) return reinterpret_cast(next); + return reinterpret_cast(global_dlsym); +} + +#ifdef __x86_64__ +__asm__(".text\n" + ".globl dlsym\n" + ".type dlsym, @function\n" + "dlsym:\n" + ".cfi_startproc\n" + "pushq %rdi\n" + ".cfi_adjust_cfa_offset 8\n" + "pushq %rsi\n" + ".cfi_adjust_cfa_offset 8\n" + "subq $8, %rsp\n" + ".cfi_adjust_cfa_offset 8\n" + "call fakeinput_dlsym_target\n" + "addq $8, %rsp\n" + ".cfi_adjust_cfa_offset -8\n" + "popq %rsi\n" + ".cfi_adjust_cfa_offset -8\n" + "popq %rdi\n" + ".cfi_adjust_cfa_offset -8\n" + "jmp *%rax\n" + ".cfi_endproc\n" + ".size dlsym, .-dlsym\n"); +#else +__asm__(".text\n" + ".globl dlsym\n" + ".type dlsym, @function\n" + "dlsym:\n" + ".cfi_startproc\n" + "subl $12, %esp\n" + ".cfi_adjust_cfa_offset 12\n" + "movl 20(%esp), %eax\n" + "movl %eax, 4(%esp)\n" + "movl 16(%esp), %eax\n" + "movl %eax, (%esp)\n" + "call fakeinput_dlsym_target\n" + "addl $12, %esp\n" + ".cfi_adjust_cfa_offset -12\n" + "jmp *%eax\n" + ".cfi_endproc\n" + ".size dlsym, .-dlsym\n"); +#endif +#endif #endif diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 2100349b2..0e95a2635 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -835,6 +835,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionPrefs.setSteamController(this@SessionActivity, id) com.droiddeck.launcher.session.SteamDeckPad.showPad(SessionPrefs.padType(id)) com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id)) + com.droiddeck.launcher.session.InputSysfsComponent.padTypeChanged(SessionPrefs.padType(id)) padType = id }, backActionsInverted = backActionsInverted, diff --git a/app/src/main/java/com/droiddeck/launcher/session/InputSysfsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/InputSysfsComponent.kt new file mode 100644 index 000000000..03ecd7e8b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/InputSysfsComponent.kt @@ -0,0 +1,179 @@ +package com.droiddeck.launcher.session + +import android.os.FileObserver +import android.os.SystemClock +import android.system.Os +import android.util.Log +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.core.SessionPart +import com.droiddeck.launcher.input.FakeInputWriter +import java.io.File + +class InputSysfsComponent( + private val nodes: File, + base: File, + private val udevData: File, + private val deckPad: Boolean, + private val steamVirtual: Boolean, +) : SessionPart() { + private val devices = File(base, "devices") + private val classDir = File(base, "class") + private val shown = HashSet() + private var observer: FileObserver? = null + private var running = false + + fun prepare(): Boolean = try { + FileUtils.clear(devices) + FileUtils.clear(classDir) + devices.mkdirs() + classDir.mkdirs() + udevData.mkdirs() + udevData.listFiles()?.forEach { if (isPadRecord(it.name)) it.delete() } + devices.isDirectory && classDir.isDirectory + } catch (e: Exception) { + Log.w(TAG, "pads: could not prepare the input sysfs: $e") + false + } + + fun binds() = listOf(devices.path + ":" + GUEST_DEVICES, classDir.path + ":" + GUEST_CLASS) + + override fun start() { + synchronized(this) { running = true } + active = this + @Suppress("DEPRECATION") + observer = object : FileObserver(nodes.path, CREATE or DELETE or MOVED_TO or MOVED_FROM or CLOSE_WRITE) { + override fun onEvent(event: Int, path: String?) { + if (path != null && NODE_FILE.matches(path)) sync() + } + }.also { it.startWatching() } + sync() + } + + override fun stop() { + observer?.stopWatching() + observer = null + if (active === this) active = null + synchronized(this) { + running = false + for (node in shown.toList()) hide(node) + } + } + + @Synchronized + fun sync() { + if (!running) return + val wanted = (0 until NODE_LIMIT).filter { node -> + File(nodes, "event$node").exists() && if (node >= FIRST_VIRTUAL) File(nodes, ".uinput-event$node.uevent").isFile + else !ringWithdrawn(deckPad, padType) + }.toSet() + for (node in shown - wanted) hide(node) + for (node in wanted - shown) show(node) + } + + private fun show(node: Int) { + val pad = (if (node >= FIRST_VIRTUAL) runCatching { File(nodes, ".uinput-event$node.uevent").readText() }.getOrNull()?.let(::virtualPad) + else ringPad(node, steamVirtual)) ?: return + try { + val input = File(devices, "input$node") + val event = File(input, "event$node") + event.mkdirs() + File(input, "uevent").writeText(pad.uevent()) + File(input, "name").writeText(pad.name + "\n") + File(input, "phys").writeText(pad.phys + "\n") + File(input, "uniq").writeText(pad.uniq + "\n") + File(input, "properties").writeText("0\n") + File(input, "id").mkdirs() + File(input, "id/bustype").writeText("%04x\n".format(pad.bus)) + File(input, "id/vendor").writeText("%04x\n".format(pad.vendor)) + File(input, "id/product").writeText("%04x\n".format(pad.product)) + File(input, "id/version").writeText("%04x\n".format(pad.version)) + File(input, "capabilities").mkdirs() + for (name in CAPABILITIES) File(input, "capabilities/$name").writeText("0\n") + link(File(input, "subsystem"), GUEST_CLASS) + File(event, "uevent").writeText("MAJOR=$MAJOR\nMINOR=${MINOR_BASE + node}\nDEVNAME=input/event$node\n") + File(event, "dev").writeText("$MAJOR:${MINOR_BASE + node}\n") + link(File(event, "subsystem"), GUEST_CLASS) + link(File(event, "device"), "$GUEST_DEVICES/input$node") + val record = udevRecord(SystemClock.elapsedRealtimeNanos() / 1000) + File(udevData, "+input:input$node").writeText(record) + File(udevData, "c$MAJOR:${MINOR_BASE + node}").writeText(record) + link(File(classDir, "input$node"), "$GUEST_DEVICES/input$node") + link(File(classDir, "event$node"), "$GUEST_DEVICES/input$node/event$node") + shown.add(node) + Log.i(TAG, "pads: /dev/input/event$node listed for udev as \"${pad.name}\" (%04x:%04x)".format(pad.vendor, pad.product)) + } catch (e: Exception) { + Log.w(TAG, "pads: could not list /dev/input/event$node for udev: $e") + hide(node) + } + } + + private fun hide(node: Int) { + File(classDir, "event$node").delete() + File(classDir, "input$node").delete() + File(udevData, "c$MAJOR:${MINOR_BASE + node}").delete() + File(udevData, "+input:input$node").delete() + File(devices, "input$node").let { FileUtils.clear(it); it.delete() } + if (shown.remove(node)) Log.i(TAG, "pads: /dev/input/event$node no longer listed for udev") + } + + private fun link(file: File, target: String) { + file.delete() + Os.symlink(target, file.path) + } + + data class Pad( + val bus: Int, + val vendor: Int, + val product: Int, + val version: Int, + val name: String, + val phys: String, + val uniq: String, + ) { + fun uevent(): String = + "PRODUCT=%x/%x/%x/%x\nNAME=\"$name\"\nPHYS=\"$phys\"\nUNIQ=\"$uniq\"\nPROP=0\n".format(bus, vendor, product, version) + } + + companion object { + private const val TAG = "SessionService" + const val GUEST_DEVICES = "/sys/devices/virtual/input" + const val GUEST_CLASS = "/sys/class/input" + private const val MAJOR = 13 + private const val MINOR_BASE = 64 + private const val FIRST_VIRTUAL = 16 + private const val NODE_LIMIT = 64 + private val NODE_FILE = Regex("(\\.uinput-)?event\\d+(\\.uevent)?") + private val CAPABILITIES = listOf("ev", "key", "rel", "abs", "msc", "sw", "led", "snd", "ff") + + @Volatile private var active: InputSysfsComponent? = null + @Volatile private var padType = -1 + + fun padTypeChanged(type: Int) { + padType = type + active?.let { Thread(it::sync, "input-sysfs").start() } + } + + internal fun ringWithdrawn(deckPad: Boolean, type: Int) = deckPad && type != FakeInputWriter.PAD_TYPE_XBOX + + internal fun isPadRecord(name: String) = + Regex("c$MAJOR:(\\d+)").matchEntire(name)?.groupValues?.get(1)?.toInt()?.let { it in MINOR_BASE until MINOR_BASE + NODE_LIMIT } == true || + Regex("\\+input:input(\\d+)").matchEntire(name)?.groupValues?.get(1)?.toInt()?.let { it < NODE_LIMIT } == true + + internal fun udevRecord(initializedUs: Long) = + "I:$initializedUs\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\nE:ID_SERIAL=noserial\nG:seat\nG:uaccess\nQ:seat\nQ:uaccess\nV:1\n" + + internal fun ringPad(node: Int, steamVirtual: Boolean) = if (steamVirtual) { + Pad(3, 0x28de, 0x11ff, 0x110, "Microsoft X-Box 360 pad $node", "usb-fakeinput/input$node", "0000000000%02d".format(node)) + } else { + Pad(3, 0x045e, 0x028e, 0x110, "Xbox 360 Controller ($node)", "usb-fakeinput/input$node", "0000000000%02d".format(node)) + } + + internal fun virtualPad(uevent: String): Pad? { + val values = uevent.lines().mapNotNull { line -> + line.indexOf('=').takeIf { it > 0 }?.let { line.substring(0, it) to line.substring(it + 1).trim('"') } + }.toMap() + val id = values["PRODUCT"]?.split('/')?.mapNotNull { it.toIntOrNull(16) }?.takeIf { it.size == 4 } ?: return null + return Pad(id[0], id[1], id[2], id[3], values["NAME"] ?: return null, values["PHYS"].orEmpty(), values["UNIQ"].orEmpty()) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index d3627cee9..da3e5b506 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -59,6 +59,7 @@ class SessionService : Service() { private val components = java.util.concurrent.CopyOnWriteArrayList() private var deckBinds: List = emptyList() + private var inputSysfs: InputSysfsComponent? = null private val stopLock = Any() private var wakeLock: PowerManager.WakeLock? = null private var wifiLock: WifiManager.WifiLock? = null @@ -411,6 +412,7 @@ class SessionService : Service() { val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists() SessionState.deckPad = false deckBinds = emptyList() + inputSysfs = null if (controllersOn) { addControllerEnvironment(guest, fakeInputDir, sessionDir) SessionState.padBridge?.syncConnectedControllers() @@ -729,6 +731,7 @@ class SessionService : Service() { val padType = SessionPrefs.steamController(this) guest.add("FAKE_EVDEV_PAD_TYPE=$padType") com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(padType)) + InputSysfsComponent.padTypeChanged(SessionPrefs.padType(padType)) if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let { guest.add("FAKE_TOUCHCTL_RING=" + it.path) } @@ -742,6 +745,9 @@ class SessionService : Service() { if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0") guest.add("FAKE_EVDEV_LOG=1") guest.add("FAKE_EVDEV_LOG_FILE=" + File(sessionDir, "pad.log").path) + inputSysfs = InputSysfsComponent(fakeInputDir, File(fakeInputDir.parentFile!!.parentFile!!, "sys/input"), + File(LinuxRuntime.rootDir(this), "run/udev/data"), SessionState.deckPad, !uinput && SessionState.mode == MODE_STEAM) + .takeIf { it.prepare() } SessionState.fakeInputDir = fakeInputDir } @@ -756,6 +762,11 @@ class SessionService : Service() { val binds = ArrayList() if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input") if (controllersOn) binds.addAll(deckBinds) + if (controllersOn) inputSysfs?.let { + it.attach(this) + components.add(it) + binds.addAll(it.binds()) + } val battery = BatteryComponent( File(filesDir, "session/sys/power_supply"), File(LinuxRuntime.rootDir(this), "run/vpower"), diff --git a/app/src/test/java/com/droiddeck/launcher/session/InputSysfsComponentTest.kt b/app/src/test/java/com/droiddeck/launcher/session/InputSysfsComponentTest.kt new file mode 100644 index 000000000..0cb8e2843 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/InputSysfsComponentTest.kt @@ -0,0 +1,57 @@ +package com.droiddeck.launcher.session + +import com.droiddeck.launcher.input.FakeInputWriter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class InputSysfsComponentTest { + @Test fun steamInputsVirtualPadKeepsItsIdentity() { + val pad = InputSysfsComponent.virtualPad("PRODUCT=3/28de/11ff/1\nNAME=\"Microsoft X-Box 360 pad 0\"\nPHYS=\"\"\n")!! + assertEquals(listOf(3, 0x28de, 0x11ff, 1), listOf(pad.bus, pad.vendor, pad.product, pad.version)) + assertEquals("Microsoft X-Box 360 pad 0", pad.name) + assertEquals("", pad.phys) + assertTrue(pad.uevent().startsWith("PRODUCT=3/28de/11ff/1\nNAME=\"Microsoft X-Box 360 pad 0\"\n")) + } + + @Test fun anUnreadableDescriptionIsNotListed() { + assertNull(InputSysfsComponent.virtualPad("")) + assertNull(InputSysfsComponent.virtualPad("PRODUCT=3/28de\nNAME=\"pad\"\n")) + assertNull(InputSysfsComponent.virtualPad("PRODUCT=3/28de/11ff/1\n")) + } + + @Test fun ringPadsMatchWhatTheirIoctlsAnswer() { + val xbox = InputSysfsComponent.ringPad(1, steamVirtual = false) + assertEquals("Xbox 360 Controller (1)", xbox.name) + assertEquals(listOf(0x045e, 0x028e, 0x110), listOf(xbox.vendor, xbox.product, xbox.version)) + val steam = InputSysfsComponent.ringPad(0, steamVirtual = true) + assertEquals("Microsoft X-Box 360 pad 0", steam.name) + assertEquals(listOf(0x28de, 0x11ff), listOf(steam.vendor, steam.product)) + } + + @Test fun ringPadsAreHiddenWhereGamesCannotOpenThem() { + assertFalse(InputSysfsComponent.ringWithdrawn(deckPad = false, type = FakeInputWriter.PAD_TYPE_DECK)) + assertFalse(InputSysfsComponent.ringWithdrawn(deckPad = true, type = FakeInputWriter.PAD_TYPE_XBOX)) + assertTrue(InputSysfsComponent.ringWithdrawn(deckPad = true, type = FakeInputWriter.PAD_TYPE_DECK)) + assertTrue(InputSysfsComponent.ringWithdrawn(deckPad = true, type = -1)) + } + + @Test fun onlyThePadsOwnUdevRecordsAreCleared() { + assertTrue(InputSysfsComponent.isPadRecord("c13:64")) + assertTrue(InputSysfsComponent.isPadRecord("c13:80")) + assertTrue(InputSysfsComponent.isPadRecord("+input:input16")) + assertFalse(InputSysfsComponent.isPadRecord("c13:63")) + assertFalse(InputSysfsComponent.isPadRecord("c13:128")) + assertFalse(InputSysfsComponent.isPadRecord("c240:16")) + assertFalse(InputSysfsComponent.isPadRecord("+input:input64")) + } + + @Test fun theUdevRecordMarksAnInitializedJoystick() { + val record = InputSysfsComponent.udevRecord(42).lines() + assertEquals("I:42", record.first()) + assertTrue("E:ID_INPUT_JOYSTICK=1" in record) + assertTrue("E:ID_INPUT=1" in record) + } +}