feat: the pad as SteamOS has it - Steam Input's virtual pads for games, a Steam Deck controller for the client (#86)

* feat: stand in for /dev/uinput so Steam Input's virtual pads reach games

* feat: present the pad to the Steam client as a Steam Deck controller over hidraw

* docs: log the uinput stand-in and the Steam Deck controller

* feat: feed the handheld's gyro and accelerometer to the Steam Deck controller

* feat: let the Steam page choose between the Steam Deck controller and the Xbox 360 pad

* feat: Deck back grips and trackpads on the second screen

* fix: serialize ring teardown with its writers, fall back to the Xbox pad when the Deck cannot be set up, and read motion only for a Deck

* fix: keep the second screen's choice through sleep and bring it back on wake
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-09-29 21:57:52 -04:00
1 parent 5a61d37c5d
commit 75593cfd29
20 files changed
+2003 -65

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -104,6 +104,7 @@ class MainActivity : ComponentActivity() {
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var steamDeckMode by mutableStateOf(false)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showCredits by mutableStateOf(false)
private var showProtons by mutableStateOf(false)
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
@@ -808,6 +809,7 @@ class MainActivity : ComponentActivity() {
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
@@ -864,6 +866,7 @@ class MainActivity : ComponentActivity() {
steamDeckMode = on
steamChannel = SessionPrefs.steamChannel(this)
},
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
onRunSteamAtStartup = { on ->
SessionPrefs.setRunSteamAtStartup(this, on)
runSteamAtStartup = on
@@ -949,6 +952,7 @@ class MainActivity : ComponentActivity() {
fexPreset = SessionPrefs.fexPreset(this)
steamChannel = SessionPrefs.steamChannel(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
refreshAddedGames()
@@ -91,6 +91,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private lateinit var loading: LoadingState
private lateinit var hud: PerfHud
private var padBridge: PadBridge? = null
/** The handheld's gyro and accelerometer, fed to the Deck controller while the session shows. */
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
/** Between onResume and onPause. */
private var resumed = false
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion() } }
/** Motion is read while the session shows and only if the pad is a Deck controller - which
* the service can decide after this activity has resumed (SessionState.deckPadListener). */
private fun updatePadMotion() {
if (resumed && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
}
private var onScreenControls: OnScreenControls? = null
private var keyboard: KeyboardHost? = null
private var watching = true
@@ -165,9 +176,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var secondScreenMode by mutableStateOf(SecondScreenMode.NONE)
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
private var selectedSecondScreenDisplay by mutableStateOf(-1)
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
/** Between onStart and onStop: while stopped the second screen stays closed, its choice kept. */
private var started = false
private lateinit var displayManager: DisplayManager
private var secondScreenPresentation: SecondScreenPresentation? = null
private val displayListener = object : DisplayManager.DisplayListener {
@@ -223,6 +236,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
padBridge = bridge
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
@Suppress("DEPRECATION")
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
}
SessionState.deckPadListener = deckPadListener
// A player on the pad or the on-screen controls has no use for the mouse arrow; the next
// touchpad or mouse move brings it back (showCursor), once the pad has been quiet a moment.
bridge.setOnPlayerInput {
@@ -1274,11 +1292,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
if (selectedSecondScreenDisplay !in newIds) selectedSecondScreenDisplay = candidates.firstOrNull()?.id ?: -1
if (secondScreenMode != SecondScreenMode.NONE && candidates.isEmpty()) closeSecondScreen(reset = true)
// A choice kept while its display was away (asleep, waking): back as soon as it is.
else if (started && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
}
private fun selectSecondScreenDisplay(displayId: Int) {
if (secondScreenDisplays.none { it.id == displayId }) return
selectedSecondScreenDisplay = displayId
SessionState.secondScreenDisplay = displayId
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
}
@@ -1300,8 +1321,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun showSecondScreen(mode: SecondScreenMode) {
val target = displayManager.getDisplay(selectedSecondScreenDisplay)
if (target == null || !target.isValid || (target.flags and Display.FLAG_PRESENTATION) == 0) {
closeSecondScreen(reset = true)
refreshSecondScreenDisplays()
// Not there right now - a panel still waking, say. The choice is kept; the display
// listener shows it again when the display comes back (refreshSecondScreenDisplays).
closeSecondScreen(reset = false)
return
}
var presentation = secondScreenPresentation
@@ -1333,6 +1355,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (!presentation.isShowing) presentation.show()
presentation.showMode(mode)
secondScreenMode = mode
SessionState.secondScreenMode = mode
SessionState.secondScreenDisplay = selectedSecondScreenDisplay
} catch (e: Exception) {
Log.w(TAG, "could not show second-screen controls on ${target.name}", e)
closeSecondScreen(reset = true)
@@ -1352,7 +1376,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun closeSecondScreen(reset: Boolean) {
secondScreenPresentation?.closeControls()
secondScreenPresentation = null
if (reset) secondScreenMode = SecondScreenMode.NONE
if (reset) {
secondScreenMode = SecondScreenMode.NONE
SessionState.secondScreenMode = SecondScreenMode.NONE
}
}
/** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */
@@ -1465,15 +1492,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onStart() {
super.onStart()
started = true
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
refreshSecondScreenDisplays()
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
SessionService.setActivityVisible(this, true)
}
override fun onStop() {
started = false
displayManager.unregisterDisplayListener(displayListener)
closeSecondScreen(reset = true)
// Closed while the session is out of sight (sleep, a closed lid, another app), and kept:
// onStart puts it back.
closeSecondScreen(reset = false)
SessionService.setActivityVisible(this, false)
super.onStop()
}
@@ -1510,6 +1541,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
onScreenControls?.reload()
updateOnScreenControls()
resumed = true
updatePadMotion()
readPrefs()
if (CompositorHost.isStarted) applyFrameGen()
}
@@ -1523,6 +1556,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
releaseDrawerDirection()
// A button held when the app goes away would stay held in the ring for the whole session.
onScreenControls?.releaseAll()
resumed = false
updatePadMotion()
keyboard?.takeIf { it.shown }?.hide()
super.onPause()
}
@@ -1576,6 +1611,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
closeSecondScreen(reset = true)
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
padMotion?.stop()
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
if (SessionState.endListener === endListener) SessionState.endListener = null
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
super.onDestroy()
@@ -144,6 +144,7 @@ object DeviceReport {
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
k("No GL error checks", SessionPrefs.noGlError(context))
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
k("Steam controller", SessionPrefs.steamController(context))
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
k("Skip xalia", SessionPrefs.noXalia(context))
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
@@ -0,0 +1,69 @@
package com.droiddeck.launcher.input
/**
* The Steam Deck controller's back grips and trackpads, which a handheld's own pad does not have:
* the second screen's Deck controls (DeckControlsPanel) drive them, and each change goes into the
* pad's ring ([FakeInputWriter.writeDeckControls]) for libfakeinput's Deck report.
*
* Trackpad positions are -1..1 each way with y up, as on a Deck; a pad touched reports its
* position, a pad clicked also reports full pressure.
*/
object DeckControls {
/** Bits of the ring's controls word - MUST match DECK_EXTRA_* in fakeinput_steam.cpp. */
const val L4 = 1
const val R4 = 2
const val L5 = 4
const val R5 = 8
private const val LEFT_TOUCH = 16
private const val RIGHT_TOUCH = 32
private const val LEFT_CLICK = 64
private const val RIGHT_CLICK = 128
/** The pad the Deck controller is made from (PadBridge's slot). */
private const val SLOT = 0
private var controls = 0
private val pads = ShortArray(4)
private val pressure = ShortArray(2)
@Synchronized
fun setGrip(bit: Int, down: Boolean) {
set(bit, down)
publish()
}
/** A finger on (or off) one trackpad at x, y in -1..1, y up. */
@Synchronized
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) {
val at = if (right) 2 else 0
set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching)
pads[at] = if (touching) axis(x) else 0
pads[at + 1] = if (touching) axis(y) else 0
publish()
}
@Synchronized
fun setClick(right: Boolean, down: Boolean) {
set(if (right) RIGHT_CLICK else LEFT_CLICK, down)
pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0
publish()
}
/** Everything let go - for when the panel goes away with a finger still on it. */
@Synchronized
fun releaseAll() {
if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return
controls = 0
pads.fill(0)
pressure.fill(0)
publish()
}
private fun set(bit: Int, on: Boolean) {
controls = if (on) controls or bit else controls and bit.inv()
}
private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure)
}
@@ -67,6 +67,9 @@ public class FakeInputWriter {
/** Bit 10 of the snapshot word. The first ten are PadState's own bit order; the Steam
* button is not, because PadState keeps 10 and 11 for the triggers. */
private static final int SNAPSHOT_IDX_MODE = 10;
/** Bit 11: the Quick Access button. Snapshot only - an Xbox pad's evdev node has no such key;
* libfakeinput's Deck controller reads it from here. */
private static final int SNAPSHOT_IDX_QAM = 11;
private static final int EVENT_SIZE = 24;
static final int MAX_EVENTS_PER_UPDATE = 32;
@@ -92,6 +95,18 @@ public class FakeInputWriter {
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
private static final String RING_DIR_NAME = "fakeinput-rings";
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
private static final int IMU_OFFSET = RING_SIZE;
private static final int IMU_MAGIC = 0x31554D49; // IMU1
private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8;
private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3]
private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3]
private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y
private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits
private static final int RING_FILE_SIZE = RING_SIZE + 64;
private static final Object RING_LOCK = new Object();
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
@@ -110,6 +125,7 @@ public class FakeInputWriter {
private volatile boolean destroyed = false;
private final boolean[] prevButtonStates = new boolean[12];
private boolean prevQam;
private int prevThumbLX, prevThumbLY, prevThumbRX, prevThumbRY;
private int prevTriggerL, prevTriggerR;
private int prevHatX, prevHatY;
@@ -217,30 +233,40 @@ public class FakeInputWriter {
for (int i = 0; i < 8; i++) {
data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0);
}
data.putInt(IMU_OFFSET, 0);
data.putLong(IMU_SEQ_OFFSET, 0L);
for (int i = 0; i < 12; i++) {
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
}
data.putInt(DECK_CONTROLS_OFFSET, 0);
}
// Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and
// re-checks it there: a writer that looked the slot up just before this ran finds it gone.
private static void releaseRingSlotLocked(int slot) {
RingSlot ringSlot = RING_SLOTS[slot];
if (ringSlot == null) {
return;
}
ringSlot.data = null;
if (ringSlot.ringChannel != null) {
try {
ringSlot.ringChannel.close();
} catch (IOException ignored) {
synchronized (ringSlot) {
ringSlot.data = null;
if (ringSlot.ringChannel != null) {
try {
ringSlot.ringChannel.close();
} catch (IOException ignored) {
}
ringSlot.ringChannel = null;
}
ringSlot.ringChannel = null;
}
if (ringSlot.ringRaf != null) {
try {
ringSlot.ringRaf.close();
} catch (IOException ignored) {
if (ringSlot.ringRaf != null) {
try {
ringSlot.ringRaf.close();
} catch (IOException ignored) {
}
ringSlot.ringRaf = null;
}
if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) {
ringSlot.ringFile.delete();
}
ringSlot.ringRaf = null;
}
if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) {
ringSlot.ringFile.delete();
}
RING_SLOTS[slot] = null;
}
@@ -289,9 +315,9 @@ public class FakeInputWriter {
FileChannel channel = null;
try {
raf = new RandomAccessFile(ringFile, "rw");
raf.setLength(RING_SIZE);
raf.setLength(RING_FILE_SIZE);
channel = raf.getChannel();
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_SIZE);
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_FILE_SIZE);
initializeRingHeader(data);
RingSlot ringSlot = new RingSlot();
@@ -332,6 +358,9 @@ public class FakeInputWriter {
return false;
}
synchronized (ringSlot) {
if (ringSlot.data == null) {
return false;
}
if (!ringSlot.active) {
if (ringSlot.everActivated) {
ringSlot.generation++;
@@ -381,6 +410,9 @@ public class FakeInputWriter {
ByteBuffer source = this.buffer.duplicate();
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) {
return false;
}
long writeSeq = ring.getLong(RING_WRITE_SEQ_OFFSET);
int sourceLimit = source.limit();
while (source.remaining() >= EVENT_SIZE) {
@@ -416,6 +448,9 @@ public class FakeInputWriter {
buttons |= (1 << i);
}
}
if (this.prevQam) {
buttons |= (1 << SNAPSHOT_IDX_QAM);
}
long seq = ring.getLong(RING_SNAPSHOT_SEQ_OFFSET);
ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 1); // odd: write in progress
nativeStoreFence();
@@ -434,6 +469,58 @@ public class FakeInputWriter {
ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 2); // even: write complete
}
/**
* Publishes the pad's motion for slot {@code slot}: accelerometer and gyro, already in the Deck
* controller's axes and units (see PadMotion). Safe from any thread.
*/
public static void writeMotion(int slot, short[] accel, short[] gyro) {
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) return;
long seq = ring.getLong(IMU_SEQ_OFFSET);
ring.putLong(IMU_SEQ_OFFSET, seq + 1); // odd: write in progress
nativeStoreFence();
for (int i = 0; i < 3; i++) {
ring.putShort(IMU_ACCEL_OFFSET + i * 2, accel[i]);
ring.putShort(IMU_GYRO_OFFSET + i * 2, gyro[i]);
}
ring.putInt(IMU_OFFSET, IMU_MAGIC);
nativeStoreFence();
ring.putLong(IMU_SEQ_OFFSET, seq + 2);
}
}
/**
* Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls):
* {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and
* {@code pressure} left, right, already in the Deck's units. Safe from any thread.
*/
public static void writeDeckControls(int slot, int controls, short[] pads, short[] pressure) {
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) return;
long seq = ring.getLong(IMU_SEQ_OFFSET);
ring.putLong(IMU_SEQ_OFFSET, seq + 1);
nativeStoreFence();
for (int i = 0; i < 4; i++) ring.putShort(DECK_PADS_OFFSET + i * 2, pads[i]);
for (int i = 0; i < 2; i++) ring.putShort(DECK_PRESSURE_OFFSET + i * 2, pressure[i]);
ring.putInt(DECK_CONTROLS_OFFSET, controls);
ring.putInt(IMU_OFFSET, IMU_MAGIC);
nativeStoreFence();
ring.putLong(IMU_SEQ_OFFSET, seq + 2);
}
}
private static short clampShort(int value) {
if (value > Short.MAX_VALUE) {
return Short.MAX_VALUE;
@@ -512,6 +599,11 @@ public class FakeInputWriter {
}
}
if (prevQam) {
prevQam = false;
hasChanges = true;
}
// Zero all axes
if (prevThumbLX != 0) {
prevThumbLX = 0;
@@ -620,6 +712,11 @@ public class FakeInputWriter {
// reports as button 8 - exactly the "guide:b8" in the mapping Steam writes for this pad.
writeButton(SNAPSHOT_IDX_MODE,
state.isDown(PadState.GUIDE));
boolean qam = state.isDown(PadState.QAM);
if (forceResend || qam != prevQam) {
prevQam = qam;
hasChanges = true;
}
// Sticks
int lx = (int) (state.leftX * 32767);
@@ -7,6 +7,8 @@ import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import com.droiddeck.launcher.session.SessionState;
import java.io.File;
/**
@@ -19,7 +21,10 @@ import java.io.File;
* SDL → Big Picture.
*
* <p>The identity the interposer reports is an Xbox 360 pad, which is what makes SDL apply a known
* mapping without the user configuring anything.
* mapping without the user configuring anything. In a Steam session it is a Steam Deck controller
* to the client instead ({@link com.droiddeck.launcher.session.SteamDeckPad}), which has a Quick
* Access button of its own; an Xbox pad has none, so there the client's menu is reached with the
* Guide-then-A chord the client itself answers.
*/
public final class PadBridge {
private static final String TAG = "PadBridge";
@@ -54,6 +59,8 @@ public final class PadBridge {
private boolean systemQamPressed;
private boolean qamChordActive;
private boolean qamSyntheticAPressed;
/** The Deck controller's Quick Access button, held for one tap. */
private boolean qamTapPressed;
private int qamChordGeneration;
/** Told on the main thread when a player uses the pad or the on-screen controls; see [setOnPlayerInput]. */
private volatile Runnable onPlayerInput;
@@ -77,6 +84,7 @@ public final class PadBridge {
systemQamPressed = false;
qamChordActive = false;
qamSyntheticAPressed = false;
qamTapPressed = false;
qamChordGeneration++;
if (open) {
state.clear();
@@ -204,7 +212,8 @@ public final class PadBridge {
boolean qamStarted = qamPressed && !systemQamPressed;
systemGuidePressed = guidePressed;
systemQamPressed = qamPressed;
if (qamStarted) startQamChord();
// A Deck controller's QAM button is held for as long as the touch one is.
if (qamStarted && !SessionState.getDeckPad()) startQamChord();
else publish();
}
@@ -217,9 +226,23 @@ public final class PadBridge {
publish();
}
/** Starts the same Guide-then-A sequence used by the touch QAM button. */
/** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */
public synchronized void triggerQam() {
startQamChord();
if (!SessionState.getDeckPad()) {
startQamChord();
return;
}
if (qamTapPressed) return;
qamTapPressed = true;
int generation = qamChordGeneration;
publish();
chordHandler.postDelayed(() -> releaseQamTap(generation), QAM_A_HOLD_MS);
}
private synchronized void releaseQamTap(int generation) {
if (generation != qamChordGeneration || !qamTapPressed) return;
qamTapPressed = false;
publish();
}
private void startQamChord() {
@@ -258,16 +281,17 @@ public final class PadBridge {
private void publish() {
if (!open && !start()) return;
if (systemGuidePressed || qamChordActive) {
effectiveState.copyFrom(state);
effectiveState.press(PadState.GUIDE, true);
if (qamSyntheticAPressed) {
effectiveState.press(PadState.A, state.isDown(PadState.A) || qamSyntheticAPressed);
}
writer.writePad(effectiveState);
} else {
boolean guide = systemGuidePressed || qamChordActive;
boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed);
if (!guide && !qam) {
writer.writePad(state);
return;
}
effectiveState.copyFrom(state);
if (guide) effectiveState.press(PadState.GUIDE, true);
if (qam) effectiveState.press(PadState.QAM, true);
if (qamSyntheticAPressed) effectiveState.press(PadState.A, true);
writer.writePad(effectiveState);
}
private static float axis(MotionEvent event, int axis) {
@@ -0,0 +1,103 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.view.Surface
import kotlin.math.roundToInt
/**
* The handheld's own gyro and accelerometer, as the Steam Deck controller's (SteamDeckPad).
*
* The phone is the controller's body, so its motion sensors are the ones a Deck's would be. Android
* reports them in the device's natural orientation; they are turned to the screen as it is shown
* (x right, y up, z towards the player - SDL's frame for a gamepad), then to the Deck's own axes
* and units as SDL's Deck driver undoes them (SDL_hidapi_steamdeck.c): X right, Y away from the
* player, Z up; 1 g = 16384, 2000 °/s = 32768. The Steam client calibrates the gyro itself.
*
* Sensors run only while the session is on screen with the pad a Deck controller, sampled as often
* as the Deck reports (4 ms); each reading goes straight into the pad's ring
* ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report picks up the latest.
*/
class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener {
private val sensors = context.getSystemService(SensorManager::class.java)
private var thread: HandlerThread? = null
/** Readings are taken only while this is set; a callback already queued when [stop] ran drops. */
@Volatile private var active = false
// Touched only on the worker thread.
private val accel = ShortArray(3)
private val gyro = ShortArray(3)
fun start() {
if (thread != null || sensors == null) return
val gyroscope = sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
val accelerometer = sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
if (gyroscope == null && accelerometer == null) {
Log.i(TAG, "pad motion: this device has no gyro or accelerometer")
return
}
val worker = HandlerThread("pad-motion", android.os.Process.THREAD_PRIORITY_DISPLAY).apply { start() }
thread = worker
val handler = Handler(worker.looper)
active = true
gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}")
}
fun stop() {
val worker = thread ?: return
thread = null
active = false
sensors?.unregisterListener(this)
// A still controller, not one frozen mid-turn - published on the worker, after any reading
// already queued there, so nothing can overwrite it.
Handler(worker.looper).post {
gyro.fill(0)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
}
worker.quitSafely()
}
override fun onSensorChanged(event: SensorEvent) {
if (!active) return
val values = event.values
// Natural orientation to the screen's: Android's display-rotation transform.
val (x, y) = when (rotation()) {
Surface.ROTATION_90 -> -values[1] to values[0]
Surface.ROTATION_180 -> -values[0] to -values[1]
Surface.ROTATION_270 -> values[1] to -values[0]
else -> values[0] to values[1]
}
val z = values[2]
val (target, scale) = when (event.sensor.type) {
Sensor.TYPE_GYROSCOPE -> gyro to GYRO_COUNTS_PER_RAD_S
Sensor.TYPE_ACCELEROMETER -> accel to ACCEL_COUNTS_PER_M_S2
else -> return
}
// Screen frame (right, up, towards the player) to the Deck's (right, away, up).
target[0] = counts(x * scale)
target[1] = counts(-z * scale)
target[2] = counts(y * scale)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
private fun counts(value: Float): Short = value.roundToInt().coerceIn(-32768, 32767).toShort()
companion object {
private const val TAG = "PadMotion"
/** The pad the Deck controller is made from (PadBridge's slot). */
private const val SLOT = 0
/** The Deck controller's report interval (libfakeinput: DECK_REPORT_INTERVAL_US). */
private const val SAMPLING_US = 4000
private const val ACCEL_COUNTS_PER_M_S2 = 16384f / SensorManager.GRAVITY_EARTH
private const val GYRO_COUNTS_PER_RAD_S = (32768f / 2000f) * (180f / Math.PI.toFloat())
}
}
@@ -57,5 +57,7 @@ class PadState {
const val R3 = 9
/** The Steam/Guide button, which the fake evdev node reports as BTN_MODE. */
const val GUIDE = 12
/** The Quick Access button: a Deck controller's (SteamDeckPad) only, since an Xbox pad has none. */
const val QAM = 13
}
}
@@ -8,6 +8,8 @@ enum class SecondScreenMode(val id: String, val label: String) {
NONE("none", "None"),
KEYBOARD_TRACKPAD("keyboard-trackpad", "Keyboard + trackpad"),
TERMINAL("terminal", "Terminal"),
/** The Steam Deck controller's back grips and trackpads; offered while the pad is one. */
DECK_CONTROLS("deck-controls", "Deck grips + trackpads"),
}
data class SecondScreenDisplay(val id: Int, val label: String)
@@ -8,6 +8,8 @@ object SessionPrefs {
const val SUSPEND_MANUAL = "manual"
const val SUSPEND_NEVER = "never"
const val CONTROLLER_DECK = "deck"
const val CONTROLLER_XBOX360 = "xbox360"
const val OSC_AUTO = "auto"
const val OSC_ALWAYS = "always"
const val OSC_STEAM_QAM = "steam-qam"
@@ -267,6 +269,15 @@ object SessionPrefs {
fun noGlError(context: Context): Boolean = prefs(context).getBoolean("noGlError", true)
fun setNoGlError(context: Context, on: Boolean) { prefs(context).edit().putBoolean("noGlError", on).apply() }
/**
* What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access
* button, gyro, Steam Input's full treatment - SteamDeckPad), or [CONTROLLER_XBOX360], the plain
* Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
*/
fun steamController(context: Context): String =
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
/** Runs the SteamOS gamepad client with its Quick Access performance controls. */
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", false)
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
@@ -57,6 +57,8 @@ import java.util.Locale
*/
class SessionService : Service() {
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
/** The Steam Deck controller's sysfs binds (SteamDeckPad), when this session has one. */
private var deckBinds: List<String> = emptyList()
private val stopLock = Any()
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
@@ -292,6 +294,8 @@ class SessionService : Service() {
val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() }
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
SessionState.deckPad = false
deckBinds = emptyList()
if (controllersOn) addControllerEnvironment(guest, fakeInputDir)
// The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render
// node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the
@@ -605,6 +609,12 @@ class SessionService : Service() {
/** The fake evdev pads: the ring files the app writes and the identity SDL and Steam see. */
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
// Virtual pads a client made last session (event16 and up, and their hidden rings) are
// not there any more; a client that crashed never took its own down.
fakeInputDir.listFiles()?.forEach { file ->
val node = Regex("event(\\d+)").matchEntire(file.name)?.groupValues?.get(1)?.toIntOrNull()
if (file.name.startsWith(".uinput-") || (node != null && node >= FIRST_VIRTUAL_PAD)) file.delete()
}
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
@@ -612,14 +622,34 @@ class SessionService : Service() {
// identity gets the standard layout without the user configuring the pad by hand.
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
guest.add("FAKE_EVDEV_VIBRATION=1")
// Steam Input's virtual-gamepad identity is for games the client starts, which are
// meant to see that pad. Everywhere else (the desktop, a program from the rail) it
// hides the pad: SDL ignores a Steam virtual gamepad unless it runs under Steam, so
// every SDL emulator came up with no controller. There it is a plain Xbox 360 pad.
if (SessionState.mode == MODE_STEAM) guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
val uinput = !File(Environment.getExternalStorageDirectory(), NO_UINPUT_SWITCH).exists()
// In Steam, the pad is what a Deck's is: a Steam Deck controller the client reads over
// hidraw (SteamDeckPad), which only works with Steam Input's virtual pad for games to read.
// Decided only once its sysfs is in place: without it the client would find no Deck, and
// with the Deck asked for the pad's own nodes are withdrawn - no controller at all.
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK &&
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList()
SessionState.deckPad = deckBinds.isNotEmpty()
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
if (uinput) {
// /dev/uinput, stood in for by libfakeinput: the virtual pad Steam Input makes for a
// game becomes a node the game reads, carrying the player's layout, as on a Deck.
guest.add("FAKE_EVDEV_UINPUT=1")
if (SessionState.deckPad) guest.add("FAKE_EVDEV_DECK=1")
} else if (SessionState.mode == MODE_STEAM) {
// Without it, games see the pad itself wearing Steam Input's virtual-gamepad identity -
// for games the client starts only. Everywhere else (the desktop, a program from the
// rail) that identity hides the pad: SDL ignores a Steam virtual gamepad unless it
// runs under Steam, so every SDL emulator came up with no controller.
guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
}
guest.add("SDL_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_JOYSTICK_HIDAPI=0")
// The client reads a Deck controller through SDL's HIDAPI; a hint in the environment
// outranks the client's own. Nothing else is shown the Deck (libfakeinput).
if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0")
if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) {
guest.add("FAKE_EVDEV_LOG=1")
}
@@ -630,6 +660,7 @@ class SessionService : Service() {
private fun sessionBinds(controllersOn: Boolean, fakeInputDir: File): ArrayList<String> {
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
if (controllersOn) binds.addAll(deckBinds)
// The client's battery readout (the Quick Access Menu, the top bar) reads
// /sys/class/power_supply/BAT<n>/..., a laptop's or a Deck's naming; Android's supply is
// called "battery" and its files differ, so the client sees no battery at all. A directory
@@ -1145,6 +1176,10 @@ class SessionService : Service() {
private const val STEAM_EXIT_MS = 10_000L
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val PAD_LOG_SWITCH = "Download/droiddeck-pad-log"
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
/** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */
private const val FIRST_VIRTUAL_PAD = 16
const val EXTRA_MODE = "mode"
const val MODE_STEAM = "steam"
@@ -63,6 +63,27 @@ object SessionState {
* gets DXVK_HDR=1 and gamescope --hdr-enabled. Decided by the activity before the compositor starts. */
@JvmStatic var hdr = false
/** The pad is presented to the Steam client as a Steam Deck controller this session
* (SteamDeckPad): its Quick Access button is a real button, not the Guide+A chord. Decided by
* the service as the session starts, which can be after the activity has resumed - hence
* [deckPadListener]. */
@Volatile private var deckPadValue = false
@JvmStatic var deckPad: Boolean
get() = deckPadValue
set(value) {
if (deckPadValue == value) return
deckPadValue = value
deckPadListener?.invoke()
}
/** Told (on the thread that changed it) whenever [deckPad] changes. */
@Volatile var deckPadListener: (() -> Unit)? = null
/** What the second screen shows, and on which display: kept for the session, so a screen
* turned off (sleep, a closed lid) or an activity recreated brings it back as it was. */
@Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE
@Volatile var secondScreenDisplay = -1
/** Which session this is: SessionService.MODE_STEAM or MODE_DESKTOP. */
@Volatile
var mode = "steam"
@@ -0,0 +1,105 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.system.Os
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
/**
* The session's pad, described to the Steam client as a Steam Deck controller.
*
* On a Deck - and on the handhelds InputPlumber serves as one - the client finds the controller
* through libudev as a `hidraw` device under a `hid` device under a USB interface and reads it
* itself. libfakeinput serves the device node (`/dev/hidraw16`, built from the pad's input ring);
* what the client also needs is the sysfs it walks to find and identify that node, which a sandbox
* shows nothing of. This writes that sysfs as the kernel lays out a Deck's controller interface and
* returns the binds that put it in place:
*
* - `/sys/devices/droiddeck/usb1` - the USB device (Valve, 28de:1205), its interface 2, the HID
* device on it (`HID_ID`, `report_descriptor`) and that device's `hidraw/hidraw16`;
* - `/sys/class/hidraw/hidraw16` - what an enumeration of the `hidraw` subsystem lists;
* - `/sys/dev/char/240:16` - how the node's device number is looked up (in the directory the
* runtime already binds there for the GPU);
* - `/run/udev/data/c240:16` - the udev database entry that marks the device initialised, without
* which libudev's enumeration leaves a device node out.
*
* Symlinks are absolute guest paths; proot resolves them inside the session.
*/
object SteamDeckPad {
private const val TAG = "SessionService"
/** Must match libfakeinput (fakeinput_steam.cpp: DECK_HIDRAW_*). */
private const val MAJOR = 240
private const val MINOR = 16
private const val NODE = "hidraw$MINOR"
private const val GUEST_DEVICES = "/sys/devices/droiddeck"
private const val USB = "$GUEST_DEVICES/usb1"
private const val INTERFACE = "$USB/1-1:1.2"
private const val HID = "$INTERFACE/0003:28DE:1205.0001"
private const val HIDRAW = "$HID/hidraw/$NODE"
private const val SERIAL = "DROIDDECK0001"
/** InputPlumber's CONTROLLER_DESCRIPTOR, as libfakeinput answers HIDIOCGRDESC. */
private val REPORT_DESCRIPTOR = intArrayOf(
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00,
0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x06, 0x09, 0x07,
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
fun prepare(context: Context, sessionRoot: File): List<String> {
val base = File(sessionRoot, "sys/deck")
val devices = File(base, "devices")
val hidrawClass = File(base, "class-hidraw")
return try {
FileUtils.clear(base)
fun dir(guest: String) = File(devices, guest.removePrefix("$GUEST_DEVICES/")).apply { mkdirs() }
fun write(guest: String, name: String, text: String) = File(dir(guest), name).writeText(text)
fun link(file: File, target: String) {
file.parentFile?.mkdirs()
file.delete()
Os.symlink(target, file.path)
}
write(USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1205/100\nTYPE=0/0/0\nBUSNUM=001\nDEVNUM=002\n")
write(USB, "idVendor", "28de\n")
write(USB, "idProduct", "1205\n")
write(USB, "bcdDevice", "0100\n")
write(USB, "manufacturer", "Valve Software\n")
write(USB, "product", "Steam Deck Controller\n")
write(USB, "serial", "$SERIAL\n")
link(File(dir(USB), "subsystem"), "/sys/bus/usb")
write(INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1205/100\nINTERFACE=3/0/0\n")
write(INTERFACE, "bInterfaceNumber", "02\n")
write(INTERFACE, "bInterfaceClass", "03\n")
link(File(dir(INTERFACE), "subsystem"), "/sys/bus/usb")
write(HID, "uevent", "DRIVER=hid-steam\nHID_ID=0003:000028DE:00001205\n" +
"HID_NAME=Valve Software Steam Deck Controller\nHID_PHYS=usb-droiddeck-1/input2\n" +
"HID_UNIQ=$SERIAL\nMODALIAS=hid:b0003g0001v000028DEp00001205\n")
File(dir(HID), "report_descriptor").writeBytes(REPORT_DESCRIPTOR)
link(File(dir(HID), "subsystem"), "/sys/bus/hid")
write(HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$MINOR\nDEVNAME=$NODE\n")
write(HIDRAW, "dev", "$MAJOR:$MINOR\n")
link(File(dir(HIDRAW), "subsystem"), "/sys/class/hidraw")
link(File(dir(HIDRAW), "device"), HID)
hidrawClass.mkdirs()
link(File(hidrawClass, NODE), HIDRAW)
// The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode).
link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW)
val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() }
File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
} catch (e: Exception) {
Log.w(TAG, "deck pad: could not describe the pad as a Deck controller: $e")
emptyList()
}
}
}
@@ -0,0 +1,351 @@
package com.droiddeck.launcher.ui
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RadialGradient
import android.graphics.RectF
import android.graphics.Shader
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import com.droiddeck.launcher.input.DeckControls
import kotlin.math.min
/**
* The Steam Deck controller's back grips and trackpads, on the second screen.
*
* Four tabs: the grips as a 2x2 grid (left grips on the left, upper above lower, as they sit on a
* Deck); either trackpad on its own with a bar to click it; and both trackpads with the grips in a
* row beneath. A trackpad clicks from its bar or from a second finger while the first is down.
* Drawn for OLED: true black, with outlines rather than filled surfaces, and the Steam blue only
* where something is being touched.
*/
@SuppressLint("ViewConstructor")
class DeckControlsPanel(
context: Context,
private val onClose: () -> Unit,
private val onSteamMenu: () -> Unit,
private val onQam: () -> Unit,
) : LinearLayout(context) {
private enum class Tab(val label: String) { GRIPS("Grips"), LEFT("Left pad"), RIGHT("Right pad"), BOTH("Both") }
private val content = FrameLayout(context)
private val tabViews = HashMap<Tab, TextView>()
init {
orientation = VERTICAL
setBackgroundColor(Color.BLACK)
addView(topBar(), LayoutParams(LayoutParams.MATCH_PARENT, dp(64)))
addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f))
select(selected)
}
override fun onDetachedFromWindow() {
DeckControls.releaseAll()
super.onDetachedFromWindow()
}
private fun topBar(): View = LinearLayout(context).apply {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(12), dp(10), dp(12), dp(6))
addView(roundButton("×", "Close second-screen controls", onClose))
val tabs = LinearLayout(context).apply {
orientation = HORIZONTAL
background = outline(CORNER_PILL, SURFACE)
setPadding(dp(4), dp(4), dp(4), dp(4))
for (tab in Tab.values()) {
val view = TextView(context).apply {
text = tab.label
textSize = 14f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
gravity = Gravity.CENTER
isSingleLine = true
setPadding(dp(14), 0, dp(14), 0)
setOnClickListener { select(tab) }
}
tabViews[tab] = view
addView(view, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT))
}
}
val centre = FrameLayout(context).apply {
addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44), Gravity.CENTER))
}
addView(centre, LayoutParams(0, LayoutParams.MATCH_PARENT, 1f))
addView(roundButton("STEAM", "Open Steam menu", onSteamMenu, wide = true))
addView(space(dp(8)))
addView(roundButton("•••", "Open Quick Access Menu", onQam, wide = true))
}
private fun select(tab: Tab) {
selected = tab
DeckControls.releaseAll()
for ((each, view) in tabViews) {
val on = each == tab
view.background = if (on) filled(CORNER_PILL, ACCENT) else null
view.setTextColor(if (on) ON_ACCENT else TEXT_DIM)
}
content.removeAllViews()
content.addView(
when (tab) {
Tab.GRIPS -> gripsGrid()
Tab.LEFT -> singlePad(right = false)
Tab.RIGHT -> singlePad(right = true)
Tab.BOTH -> bothPads()
},
FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT),
)
}
private fun gripsGrid(): View = LinearLayout(context).apply {
orientation = HORIZONTAL
setPadding(dp(12), dp(6), dp(12), dp(12))
fun column(upper: View, lower: View) = LinearLayout(context).apply {
orientation = VERTICAL
addView(upper, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(6) })
addView(lower, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { topMargin = dp(6) })
}
addView(column(GripButton(context, "L4", "Left · upper", DeckControls.L4), GripButton(context, "L5", "Left · lower", DeckControls.L5)),
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) })
addView(column(GripButton(context, "R4", "Right · upper", DeckControls.R4), GripButton(context, "R5", "Right · lower", DeckControls.R5)),
LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
}
private fun singlePad(right: Boolean): View = LinearLayout(context).apply {
orientation = VERTICAL
setPadding(dp(12), dp(6), dp(12), dp(12))
addView(TrackpadView(context, right), LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) })
addView(ClickBar(context, right), LayoutParams(LayoutParams.MATCH_PARENT, dp(76)))
}
private fun bothPads(): View = LinearLayout(context).apply {
orientation = VERTICAL
setPadding(dp(12), dp(6), dp(12), dp(12))
val pads = LinearLayout(context).apply {
orientation = HORIZONTAL
addView(TrackpadView(context, right = false), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) })
addView(TrackpadView(context, right = true), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) })
}
addView(pads, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) })
// Mirrored, as hands hold them: the upper grips outside, the lower ones in the middle.
val grips = LinearLayout(context).apply {
orientation = HORIZONTAL
val row = listOf("L4" to DeckControls.L4, "L5" to DeckControls.L5, "R5" to DeckControls.R5, "R4" to DeckControls.R4)
row.forEachIndexed { index, (label, bit) ->
addView(GripButton(context, label, null, bit), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply {
if (index > 0) leftMargin = dp(6)
if (index < row.size - 1) rightMargin = dp(6)
})
}
}
addView(grips, LayoutParams(LayoutParams.MATCH_PARENT, dp(84)))
}
private fun roundButton(label: String, description: String, onClick: () -> Unit, wide: Boolean = false) =
TextView(context).apply {
text = label
contentDescription = description
textSize = if (wide) 13f else 20f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
letterSpacing = if (wide) 0.08f else 0f
gravity = Gravity.CENTER
setTextColor(TEXT)
background = outline(CORNER_PILL, SURFACE)
setPadding(if (wide) dp(16) else 0, 0, if (wide) dp(16) else 0, 0)
minWidth = dp(44)
layoutParams = LayoutParams(if (wide) LayoutParams.WRAP_CONTENT else dp(44), dp(44))
setOnClickListener {
performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
onClick()
}
}
private fun space(width: Int) = View(context).apply { layoutParams = LayoutParams(width, 1) }
private fun outline(radius: Float, fill: Int) = GradientDrawable().apply {
cornerRadius = radius * resources.displayMetrics.density
setColor(fill)
setStroke(dp(1), LINE)
}
private fun filled(radius: Float, fill: Int) = GradientDrawable().apply {
cornerRadius = radius * resources.displayMetrics.density
setColor(fill)
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density + 0.5f).toInt()
/** A surface drawn the same way everywhere on the panel; pressed, it lights up in the accent. */
private abstract class Surface(context: Context) : View(context) {
protected val density = resources.displayMetrics.density
protected val rect = RectF()
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
protected val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
protected val captionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
color = TEXT_DIM
}
protected fun drawSurface(canvas: Canvas, lit: Boolean) {
val inset = density
rect.set(inset, inset, width - inset, height - inset)
val radius = CORNER * density
fill.color = if (lit) ACCENT_WASH else SURFACE
canvas.drawRoundRect(rect, radius, radius, fill)
stroke.color = if (lit) ACCENT else LINE
stroke.strokeWidth = (if (lit) 2f else 1f) * density
canvas.drawRoundRect(rect, radius, radius, stroke)
}
}
/** A held button: down while a finger is on it. */
private open class HoldButton(
context: Context,
private val label: String,
private val caption: String?,
private val onChange: (Boolean) -> Unit,
) : Surface(context) {
private var down = false
override fun onDraw(canvas: Canvas) {
drawSurface(canvas, down)
val cx = width / 2f
labelPaint.textSize = min(height * 0.3f, 30 * density)
labelPaint.color = if (down) ACCENT else TEXT
val labelY = height / 2f + labelPaint.textSize * (if (caption == null) 0.35f else 0.1f)
canvas.drawText(label, cx, labelY, labelPaint)
if (caption != null) {
captionPaint.textSize = 12 * density
canvas.drawText(caption, cx, labelY + 22 * density, captionPaint)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> set(true)
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> set(false)
}
return true
}
private fun set(pressed: Boolean) {
if (down == pressed) return
down = pressed
if (pressed) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
onChange(pressed)
invalidate()
}
}
private class GripButton(context: Context, label: String, caption: String?, bit: Int) :
HoldButton(context, label, caption, { DeckControls.setGrip(bit, it) })
private class ClickBar(context: Context, right: Boolean) :
HoldButton(context, "Click", if (right) "Right trackpad" else "Left trackpad", { DeckControls.setClick(right, it) })
/**
* One trackpad: where the first finger is, as the Deck reports it (-1..1, y up), and a click
* while a second finger is down.
*/
private class TrackpadView(context: Context, private val right: Boolean) : Surface(context) {
private var touching = false
private var clicked = false
private var fingerX = 0f
private var fingerY = 0f
private val guide = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; color = GUIDE }
private val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ACCENT }
private val glow = Paint(Paint.ANTI_ALIAS_FLAG)
override fun onDraw(canvas: Canvas) {
drawSurface(canvas, clicked)
val cx = width / 2f
val cy = height / 2f
guide.strokeWidth = density
val reach = min(width, height) / 2f - 24 * density
canvas.drawCircle(cx, cy, reach, guide)
canvas.drawCircle(cx, cy, reach / 2f, guide)
canvas.drawLine(cx - reach, cy, cx + reach, cy, guide)
canvas.drawLine(cx, cy - reach, cx, cy + reach, guide)
captionPaint.textSize = 12 * density
captionPaint.color = if (touching) ACCENT else TEXT_DIM
canvas.drawText(if (right) "RIGHT TRACKPAD" else "LEFT TRACKPAD", cx, 28 * density, captionPaint)
if (touching) {
val radius = 56 * density
glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP)
canvas.drawCircle(fingerX, fingerY, radius, glow)
canvas.drawCircle(fingerX, fingerY, (if (clicked) 16 else 12) * density, dot)
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
touch(event.getX(0), event.getY(0))
click(event.pointerCount > 1)
}
MotionEvent.ACTION_POINTER_DOWN -> click(true)
MotionEvent.ACTION_POINTER_UP -> {
// The finger that lifted may be the first; the pad follows whichever stays.
val stays = if (event.actionIndex == 0) 1 else 0
touch(event.getX(stays), event.getY(stays))
click(event.pointerCount - 1 > 1)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
click(false)
touching = false
DeckControls.setPad(right, touching = false)
invalidate()
}
}
return true
}
private fun touch(x: Float, y: Float) {
fingerX = x.coerceIn(0f, width.toFloat())
fingerY = y.coerceIn(0f, height.toFloat())
touching = true
DeckControls.setPad(right, true, (fingerX - width / 2f) / (width / 2f), -(fingerY - height / 2f) / (height / 2f))
invalidate()
}
private fun click(down: Boolean) {
if (clicked == down) return
clicked = down
if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
DeckControls.setClick(right, down)
invalidate()
}
}
companion object {
/** The tab last shown, kept for the session so reopening the panel lands where it was. */
private var selected = Tab.BOTH
private const val CORNER = 20f
private const val CORNER_PILL = 22f
private val ACCENT = Color.rgb(0x1A, 0x9F, 0xFF)
private val ACCENT_WASH = Color.argb(0x2E, 0x1A, 0x9F, 0xFF)
private val ACCENT_GLOW = Color.argb(0x66, 0x1A, 0x9F, 0xFF)
private val ON_ACCENT = Color.rgb(0x03, 0x11, 0x1F)
private val SURFACE = Color.rgb(0x0A, 0x0B, 0x0D)
private val LINE = Color.rgb(0x26, 0x29, 0x2E)
private val GUIDE = Color.rgb(0x16, 0x18, 0x1B)
private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7)
private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A)
}
}
@@ -70,6 +70,8 @@ class ModeSettings(
val steamChannel: String? = null,
/** Steam only: enable the SteamOS client interface and its performance controls. */
val steamDeckMode: Boolean = false,
/** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */
val steamController: String? = null,
/** Steam only: start a Steam session when DroidDeck opens. */
val runSteamAtStartup: Boolean = false,
/** Steam only: the user's chosen Games folders; null outside Steam. */
@@ -126,6 +128,7 @@ class ModeSettingsActions(
val onForceFullscreen: (Boolean) -> Unit = {},
val onSteamChannel: (String) -> Unit = {},
val onSteamDeckMode: (Boolean) -> Unit = {},
val onSteamController: (String) -> Unit = {},
val onRunSteamAtStartup: (Boolean) -> Unit = {},
val onPickAddedGamesDir: () -> Unit = {},
val onForgetAddedGamesDir: (path: String) -> Unit = {},
@@ -259,6 +262,16 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
), s.oscMode,
note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc,
)
if (steam && s.steamController != null) ChoiceRow(
host, "controller", "Controller", "What your controller is to Steam. Applies next session.",
listOf(
SessionPrefs.CONTROLLER_DECK to "Steam Deck controller",
SessionPrefs.CONTROLLER_XBOX360 to "Xbox 360 controller",
), s.steamController,
note = "Steam Deck controller: Steam reads it as a Deck's own, with its Quick Access button and the device's gyro. " +
"Xbox 360 controller: the plain pad of earlier versions; Quick Access opens with Guide+A.",
onPick = a.onSteamController,
)
if (steam) ChoiceRow(
host, "back-actions", "Back", SessionPrefs.backActionsOrder(s.backActionsInverted),
listOf(
@@ -112,6 +112,7 @@ class SecondScreenPresentation(
when (currentMode) {
SecondScreenMode.KEYBOARD_TRACKPAD -> renderKeyboardAndTrackpad()
SecondScreenMode.TERMINAL -> renderTerminal()
SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam), weightParams())
SecondScreenMode.NONE -> Unit
}
}
@@ -357,7 +357,9 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
}
if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup("Second screen") {
ChoiceRow(host, "second-screen-mode", "Shows", null,
listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL).map { it to it.label },
(listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) +
(if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList()))
.map { it to it.label },
a.secondScreenMode, chipModifier = focus.track(page, "second-screen-mode"), onPick = a.onSecondScreenMode)
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", "Display", null,
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
+41
View File
@@ -7,6 +7,47 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
---
## 2026-09-29 - `feat/controller-input`: the pad the way SteamOS has it
Device-tested on the AYN Thor (Katamari under Proton Experimental ARM64).
- **Why**: the client read the pad as an Xbox 360 controller, and games read the *same* node
wearing Steam Input's virtual identity (28de:11ff), because the client's own virtual pad needs
`/dev/uinput` and never appeared. Steam Input's layouts therefore never reached a game, and the
QAM was a timed Guide+A chord. InputPlumber itself cannot run in the sandbox (root, uinput, uhid,
udev, D-Bus), so what it provides is emulated in the interposer instead.
- **`/dev/uinput` stand-in** (`FAKE_EVDEV_UINPUT=1`, libfakeinput): a gamepad the client makes
becomes `/dev/input/event16+`, backed by a ring of the app's own format, with the client's name,
ids and bits; its writes are that ring's events. Other devices (virtual keyboard and mouse) are
accepted and dropped. `inputudev.c` describes these nodes to Wine's HID bus from a `.uevent` file
beside each one, and while one exists describes the app's pads as the Xbox 360 pad they are, so
Wine reads only Steam Input's output. Verified: the client makes "Microsoft X-Box 360 pad 0",
re-makes it at game launch, and the game binds it (`Controller 0 uses xinput : true`).
- **Steam Deck controller** (`FAKE_EVDEV_DECK=1`, Steam sessions): `/dev/hidraw16`, found through a
sysfs tree `SteamDeckPad.kt` binds in (usb_device → interface 2 → hid 28de:1205 → hidraw), streams
the Deck's 64-byte state report every 4 ms from ring 0 and answers feature reports as InputPlumber
does. systemd 261's libudev refuses a syspath not on sysfs, so `fstatfs`/`statfs` report sysfs
for that tree. The app's evdev nodes are withdrawn while it is on, and only the `steam` process
sees the Deck. QAM is a real button (snapshot bit 11). Verified: the client lists a Steam Deck
Controller (V1 HID protocol) and runs its handshake (0x83, 0xAE, 0x81/0x87 lizard off, 0x8F).
- **Setting**: Steam page → Touch & controls → **Controller**: *Steam Deck controller* (default) or
*Xbox 360 controller* (the pad of earlier versions, QAM by Guide+A; games still get Steam Input's
virtual pad). Debug switches: `droiddeck-no-uinput` (back to the 28de:11ff disguise),
`droiddeck-no-deck-pad` (forces the Xbox 360 pad).
- **Gyro**: `PadMotion.kt` feeds the handheld's own gyro and accelerometer (4 ms sampling, while
the session is on screen) into an IMU block after ring 0's events, turned to the screen and then
to the Deck's axes and units; the Deck report carries them. Verified: Steam's Gyro Calibration
page moves with the Thor (sh5001 IMU); at rest the accelerometer reads 1 g.
- **Deck grips + trackpads on the second screen** (`DeckControlsPanel.kt`, offered while the pad
is a Deck controller): tabs for the four grips (2x2), either trackpad with a click bar, and both
trackpads over the grips in a row; a second finger on a trackpad clicks it. True black for OLED.
Grips, pad positions, touch, click and pressure go through the same block after ring 0's events
as the gyro (`DeckControls.kt`) into the Deck report.
- **Not yet**: a Bluetooth pad's own IMU (DualSense); the
game's rumble on the virtual pad goes straight to the vibrator rather than back through the client.
- **Prior art checked**: WinNative and Bannerlator never emulated uinput or hidraw; Bannerlator's
`-steamdeck` mode stopped at "the virtual pad never arrives", which is this missing uinput.
## 2026-09-25 - main `8cdedbe`: melonDS out of the box, touch in Big Picture, log privacy, Decky
State: **main = `8cdedbe`** (PR #22 merge). Device-tested on the AYANEO Pocket FIT (repacked test builds)
+83 -4
View File
@@ -20,6 +20,14 @@
* Only Wine's device host is answered, and only for the session's own nodes: the Steam client
* enumerates the same nodes for itself and would take a pad wearing Steam Input's identity for one
* of its own and ignore it. Every other caller, path and device object goes straight through.
*
* With libfakeinput's /dev/uinput stand-in (FAKE_EVDEV_UINPUT=1) Steam Input's own virtual pads
* exist too, as event16 and up, and those are what Wine is meant to read: each is described with
* the identity the client gave it, from the .uevent file libfakeinput writes beside the node.
* While one exists the app's pads are described as what they are, an Xbox 360 controller, which
* Wine's HID bus leaves alone - the game reads Steam Input's output, not the pad under it, as on
* a Steam Deck. With none (Steam Input off for the game) they keep the virtual identity, so the
* game still has a controller.
*/
#define _GNU_SOURCE
#include <dlfcn.h>
@@ -31,8 +39,11 @@
#include <sys/sysmacros.h>
#include <sys/types.h>
/* The app opens one ring per configured controller; four is what its launcher offers. */
#define PAD_MAX 4
/* The app opens one ring per configured controller; four is what its launcher offers. Nodes from
* UINPUT_BASE up are the virtual pads made through libfakeinput's /dev/uinput stand-in. */
#define RING_MAX 4
#define UINPUT_BASE 16
#define PAD_MAX 64
#define EVENT_MINOR_BASE 64
/* BUS_VIRTUAL, and the identity Steam Input gives the gamepad it presents to a game. */
@@ -41,16 +52,23 @@
"NAME=\"Generic HID Gamepad %d\"\n" \
"PHYS=\"usb-fakeinput/input%d\"\n"
/* The identity libfakeinput gives the pad itself (FAKE_EVDEV_IDENTITY=xbox360). */
#define PAD_PHYSICAL_UEVENT_FORMAT \
"PRODUCT=3/45e/28e/110\n" \
"NAME=\"Xbox 360 Controller (%d)\"\n" \
"PHYS=\"usb-fakeinput/input%d\"\n"
struct pad {
char syspath[64];
char devnode[32];
char sysname[16];
char uevent[128];
char uevent[256];
dev_t devnum;
};
static struct pad pads[PAD_MAX];
static int pad_count;
static pthread_mutex_t virtual_lock = PTHREAD_MUTEX_INITIALIZER;
/*
* The slots the app published rings for, named in FAKE_EVDEV_MEMFD_PATHS as "<slot>=<path>"
@@ -66,7 +84,7 @@ static void discover_pads(void) {
char *end;
long slot = strtol(entry, &end, 10);
if (end != entry && *end == '=' && slot >= 0 && slot < PAD_MAX) {
if (end != entry && *end == '=' && slot >= 0 && slot < RING_MAX) {
struct pad *pad = &pads[slot];
if (pad->devnode[0] == '\0') {
@@ -90,6 +108,66 @@ static void ensure_pads(void) {
pthread_once(&once, discover_pads);
}
static int uinput_enabled(void) {
const char *enabled = getenv("FAKE_EVDEV_UINPUT");
return enabled != NULL && atoi(enabled) != 0;
}
/* Reads the .uevent libfakeinput wrote for virtual pad <node> into buf; 0 when there is none. */
static int read_virtual_uevent(int node, char *buf, size_t size) {
const char *dir = getenv("FAKE_EVDEV_DIR");
char path[PATH_MAX];
FILE *file;
size_t length;
if (dir == NULL) return 0;
snprintf(path, sizeof(path), "%s/.uinput-event%d.uevent", dir, node);
if ((file = fopen(path, "re")) == NULL) return 0;
length = fread(buf, 1, size - 1, file);
fclose(file);
buf[length] = '\0';
return length > 0;
}
static int any_virtual_pad(void) {
char uevent[256];
int node;
for (node = UINPUT_BASE; node < PAD_MAX; node++)
if (read_virtual_uevent(node, uevent, sizeof(uevent))) return 1;
return 0;
}
/*
* Brings the entry for event<node> up to date before Wine resolves it: a virtual pad comes and
* goes with the client, and the app's pads change identity with it.
*/
static void refresh_pad(int node) {
struct pad *pad;
if (node < 0 || node >= PAD_MAX) return;
pad = &pads[node];
pthread_mutex_lock(&virtual_lock);
if (node >= UINPUT_BASE) {
char uevent[sizeof(pad->uevent)];
if (read_virtual_uevent(node, uevent, sizeof(uevent))) {
snprintf(pad->sysname, sizeof(pad->sysname), "event%d", node);
snprintf(pad->syspath, sizeof(pad->syspath), "/sys/devices/virtual/input/bannerlator%d", node);
memcpy(pad->uevent, uevent, sizeof(uevent));
pad->devnum = makedev(13, EVENT_MINOR_BASE + (unsigned int)node);
snprintf(pad->devnode, sizeof(pad->devnode), "/dev/input/event%d", node);
if (node >= pad_count) pad_count = node + 1;
} else {
pad->devnode[0] = '\0';
}
} else if (pad->devnode[0] != '\0' && uinput_enabled()) {
snprintf(pad->uevent, sizeof(pad->uevent),
any_virtual_pad() ? PAD_PHYSICAL_UEVENT_FORMAT : PAD_UEVENT_FORMAT, node, node);
}
pthread_mutex_unlock(&virtual_lock);
}
/* The pad a synthetic syspath names, or NULL when the path is not one of ours. */
static struct pad *pad_for_syspath(const char *syspath) {
int i;
@@ -123,6 +201,7 @@ static struct pad *pad_for_syslink(const char *path) {
if (path == NULL || strncmp(path, prefix, sizeof(prefix) - 1) != 0) return NULL;
name = path + sizeof(prefix) - 1;
ensure_pads();
if (strncmp(name, "event", 5) == 0 && name[5] >= '0' && name[5] <= '9') refresh_pad(atoi(name + 5));
for (i = 0; i < pad_count; i++) {
if (pads[i].devnode[0] != '\0' && strcmp(pads[i].sysname, name) == 0) return &pads[i];
}