mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-15 03:37:09 -04:00
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:
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user