Merge pull request #125 from Droid-Deck/feat/input-evidence-logging

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-01 12:14:15 -04:00
commit 6782e5a717
8 files changed
+349 -35

No files matched your search

+174 -8
View File
@@ -8,6 +8,7 @@
* a game become more of these nodes instead of vanishing.
*/
#include <algorithm>
#include <atomic>
#include <cerrno>
#include <climits>
#include <cstdint>
@@ -38,6 +39,9 @@
#include <sys/inotify.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/prctl.h>
#include <time.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <sys/stat.h>
@@ -295,21 +299,52 @@ static void controller_fork_child() {
namespace Logger {
int log_enabled;
// FAKE_EVDEV_LOG_FILE: the session's pad.log, shared by every process the library is loaded in
// (the client, each game), so a pad report has the guest side in one file of the session folder.
// Opened with the raw syscall: open() is this library's own hook.
static int log_fd = -1;
static char log_name[17];
void init() {
log_enabled = getenv("FAKE_EVDEV_LOG") && atoi(getenv("FAKE_EVDEV_LOG"));
const char *path = getenv("FAKE_EVDEV_LOG_FILE");
if (log_enabled && path && *path)
log_fd = static_cast<int>(syscall(SYS_openat, AT_FDCWD, path, O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644));
if (prctl(PR_GET_NAME, log_name) != 0) strcpy(log_name, "?");
}
void log(const char *message, ...) {
if (!log_enabled)
return;
if (log_fd < 0) {
va_list args;
va_start(args, message);
vfprintf(stderr, message, args);
va_end(args);
std::cerr.flush();
return;
}
// One write per line: O_APPEND keeps lines from different processes whole.
char line[512];
struct timespec now;
clock_gettime(CLOCK_REALTIME, &now);
struct tm local;
localtime_r(&now.tv_sec, &local);
int n = snprintf(line, sizeof(line), "%02d:%02d:%02d.%03ld %d %s: ", local.tm_hour, local.tm_min,
local.tm_sec, now.tv_nsec / 1000000, getpid(), log_name);
va_list args;
va_start(args, message);
vfprintf(stderr, message, args);
if (n > 0 && n < static_cast<int>(sizeof(line)))
n += vsnprintf(line + n, sizeof(line) - n, message, args);
va_end(args);
std::cerr.flush();
if (n <= 0) return;
if (n >= static_cast<int>(sizeof(line))) {
n = sizeof(line) - 1;
line[n - 1] = '\n';
}
ssize_t ignored = write(log_fd, line, n);
(void)ignored;
}
} // namespace Logger
@@ -1268,6 +1303,14 @@ __attribute__((visibility("hidden"))) static bool process_is_steam_client() {
return known == 1;
}
// What the client starts with, so a pad log says whether it was offered a Deck at all.
__attribute__((constructor)) static void log_client_start() {
config();
if (process_is_steam_client())
Logger::log("loaded in the Steam client: deck %s, ring slot 0 %s\n", fake_deck_enabled() ? "on" : "off",
get_ring_path_for_slot(0).empty() ? "(none configured)" : get_ring_path_for_slot(0).c_str());
}
__attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char *pathname) {
return pathname && fake_deck_enabled() && !strcmp(pathname, DECK_HIDRAW_PATH);
}
@@ -1376,12 +1419,16 @@ __attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg)
__attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
if (!process_is_steam_client()) {
// Only the client reads the Deck; a game sees Steam Input's virtual pad instead.
Logger::log("deck: %s refused: not the Steam client\n", DECK_HIDRAW_PATH);
errno = ENOENT;
return -1;
}
std::string ring_path = get_ring_path_for_slot(0);
int ring_fd = ring_path.empty() ? -1 : my_open(ring_path.c_str(), O_RDONLY | O_CLOEXEC);
if (ring_fd < 0) {
Logger::log("deck: %s refused: ring %s not openable (%s)\n", DECK_HIDRAW_PATH,
ring_path.empty() ? "(none configured)" : ring_path.c_str(), strerror(errno));
errno = ENODEV;
return -1;
}
@@ -1394,12 +1441,15 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
void *mapping = mmap(nullptr, mapping_size, PROT_READ, MAP_SHARED, ring_fd, 0);
syscall(SYS_close, ring_fd);
if (mapping == MAP_FAILED || !ring_header_is_valid(static_cast<FakeInputRingHeader *>(mapping))) {
Logger::log("deck: %s refused: ring %s %s\n", DECK_HIDRAW_PATH, ring_path.c_str(),
mapping == MAP_FAILED ? "could not be mapped" : "has no valid header");
if (mapping != MAP_FAILED) munmap(mapping, mapping_size);
errno = ENODEV;
return -1;
}
int pair[2];
if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair) < 0) {
Logger::log("deck: %s refused: socketpair failed (%s)\n", DECK_HIDRAW_PATH, strerror(errno));
munmap(mapping, mapping_size);
return -1;
}
@@ -1438,8 +1488,18 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
// into /sys, so those paths are said to be sysfs; nothing else is touched.
static constexpr long SYSFS_FS_MAGIC = 0x62656572;
// FAKE_DECK_SYSFS_LISTING: stand-ins for the listings of /sys, /sys/class and /sys/bus
// (SteamDeckPad.listingDir), for a client that is not allowed to list the real ones.
__attribute__((visibility("hidden"))) static const char *deck_listing_dir() {
static const char *dir = getenv("FAKE_DECK_SYSFS_LISTING");
return dir && *dir ? dir : nullptr;
}
__attribute__((visibility("hidden"))) static bool is_deck_sysfs_path(const char *path) {
return path && (!strncmp(path, "/sys/devices/droiddeck", 22) || !strncmp(path, "/sys/class/hidraw", 17));
if (!path) return false;
if (!strncmp(path, "/sys/devices/droiddeck", 22) || !strncmp(path, "/sys/class/hidraw", 17)) return true;
const char *listing = deck_listing_dir();
return listing && !strncmp(path, listing, strlen(listing));
}
__attribute__((visibility("hidden"))) static bool is_deck_sysfs_fd(int fd) {
@@ -1451,17 +1511,32 @@ __attribute__((visibility("hidden"))) static bool is_deck_sysfs_fd(int fd) {
return is_deck_sysfs_path(path);
}
// Once per process: proof the client's enumeration reached the Deck's sysfs at all. No line here
// and no open of /dev/hidraw16 means the client never looked; a line here and no open means it
// looked and turned the device down.
__attribute__((visibility("hidden"))) static void note_deck_sysfs(const char *how, int result) {
static std::atomic<bool> noted{false};
if (!noted.exchange(true))
Logger::log("deck: sysfs checked (%s%s)\n", how, result == 0 ? "" : ", failed");
}
EXPORT int fstatfs(int fd, struct statfs *buf) {
static auto real = reinterpret_cast<int (*)(int, struct statfs *)>(dlsym(RTLD_NEXT, "fstatfs"));
int result = real(fd, buf);
if (result == 0 && fake_deck_enabled() && is_deck_sysfs_fd(fd)) buf->f_type = SYSFS_FS_MAGIC;
if (fake_deck_enabled() && is_deck_sysfs_fd(fd)) {
note_deck_sysfs("fstatfs", result);
if (result == 0) buf->f_type = SYSFS_FS_MAGIC;
}
return result;
}
EXPORT int statfs(const char *path, struct statfs *buf) {
static auto real = reinterpret_cast<int (*)(const char *, struct statfs *)>(dlsym(RTLD_NEXT, "statfs"));
int result = real(path, buf);
if (result == 0 && fake_deck_enabled() && is_deck_sysfs_path(path)) buf->f_type = SYSFS_FS_MAGIC;
if (fake_deck_enabled() && is_deck_sysfs_path(path)) {
note_deck_sysfs(path, result);
if (result == 0) buf->f_type = SYSFS_FS_MAGIC;
}
return result;
}
@@ -1483,6 +1558,15 @@ __attribute__((visibility("hidden"))) static int copy_ioctl_string(ioctl_request
}
// Feature reports carry the report id (0) first, then the Valve message: type, length, payload.
// The client repeats some feature reports (settings, rumble) for as long as it has the pad; each
// is logged the first time and then at 2, 4, 8... so the log shows what it asked and how often
// without a line per report.
__attribute__((visibility("hidden"))) static void log_deck_feature(const char *what, uint8_t feature) {
static std::atomic<unsigned> counts[2][256];
unsigned n = ++counts[what[0] == 's'][feature];
if ((n & (n - 1)) == 0) Logger::log("deck: feature 0x%02x %s (x%u)\n", feature, what, n);
}
__attribute__((visibility("hidden"))) static int
deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) {
size_t size = _IOC_SIZE(op);
@@ -1496,7 +1580,7 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) {
int left = buf[6] | buf[7] << 8, right = buf[8] | buf[9] << 8;
send_vibration(left, right, left || right ? 1000 : 0, 0);
}
Logger::log("deck: feature 0x%02x set\n", buf[1]);
log_deck_feature("set", buf[1]);
return static_cast<int>(size);
}
uint8_t reply[DECK_REPORT_BYTES + 1] = {};
@@ -1526,7 +1610,7 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) {
reply[1] = deck.pending_feature;
break;
}
Logger::log("deck: feature 0x%02x read\n", deck.pending_feature);
log_deck_feature("read", deck.pending_feature);
size_t length = std::min(size, sizeof(reply));
memcpy(buf, reply, length);
return static_cast<int>(length);
@@ -1570,6 +1654,80 @@ ioctl_deck(DeckHidraw &deck, ioctl_request_t op, void *argp) {
}
}
// A device scan that fails on a phone and not on the Thor: libudev gives up its whole scan on any
// sysfs directory it cannot read for a reason other than its absence, and a phone's SELinux policy
// denies directories a permissive device lets through. The client's /sys failures that are not
// ENOENT are logged, each path once, so pad.log names the directory that stopped the scan.
__attribute__((visibility("hidden"))) static std::string note_sys_failure(int dirfd, const char *pathname) {
int saved_errno = errno;
std::string path;
if (saved_errno == ENOENT || !pathname || !process_is_steam_client()) {
errno = saved_errno;
return path;
}
if (pathname[0] == '/' || dirfd == AT_FDCWD) {
path = pathname;
} else {
char link[64], dir[PATH_MAX];
snprintf(link, sizeof(link), "/proc/self/fd/%d", dirfd);
ssize_t length = readlink(link, dir, sizeof(dir) - 1);
if (length <= 0) {
errno = saved_errno;
return path;
}
path.assign(dir, length);
path += '/';
path += pathname;
}
if (path.compare(0, 5, "/sys/") != 0 && path != "/sys") {
errno = saved_errno;
return {};
}
static std::mutex mutex;
static std::vector<std::string> *seen = new std::vector<std::string>();
bool first = false;
{
std::lock_guard<std::mutex> guard(mutex);
if (seen->size() < 64 && std::find(seen->begin(), seen->end(), path) == seen->end()) {
seen->push_back(path);
first = true;
}
}
if (first) Logger::log("sysfs: %s not readable (%s)\n", path.c_str(), strerror(saved_errno));
errno = saved_errno;
return path;
}
// An enforcing SELinux policy (every retail phone; the Thor only with it forced on) refuses an app
// a listing of /sys, /sys/class and /sys/bus. libudev lists /sys/class to find the subsystems to
// descend into and gives up its whole scan when it cannot, so the client never reached hidraw and
// found no Deck. The client is listed the stand-ins instead - hidraw under class - and opens
// /sys/class/hidraw itself, which is ours and readable. Only on that refusal, only for the client.
__attribute__((visibility("hidden"))) static std::string deck_listing_for(const char *path) {
const char *listing = deck_listing_dir();
if (!path || !listing || errno != EACCES || !fake_deck_enabled() || !process_is_steam_client()) return {};
std::string p(path);
while (p.size() > 1 && p.back() == '/') p.pop_back();
int which = p == "/sys" ? 0 : p == "/sys/class" ? 1 : p == "/sys/bus" ? 2 : -1;
if (which < 0) return {};
static const char *const names[] = {"sys", "class", "bus"};
static std::atomic<int> logged{0};
if (!(logged.fetch_or(1 << which) & (1 << which)))
Logger::log("sysfs: %s listed from the Deck's stand-in\n", p.c_str());
return std::string(listing) + "/" + names[which];
}
EXPORT DIR *opendir(const char *name) {
static auto real = reinterpret_cast<DIR *(*)(const char *)>(dlsym(RTLD_NEXT, "opendir"));
DIR *dir = real(name);
if (!dir) {
note_sys_failure(AT_FDCWD, name);
std::string listing = deck_listing_for(name);
if (!listing.empty()) dir = real(listing.c_str());
}
return dir;
}
EXPORT int open(const char *pathname, int flags, ...) {
va_list va;
mode_t mode;
@@ -1633,6 +1791,10 @@ EXPORT int open(const char *pathname, int flags, ...) {
fd = my_open(pathname, flags, mode);
else
fd = my_open(pathname, flags);
if (fd < 0) {
std::string listing = deck_listing_for(note_sys_failure(AT_FDCWD, pathname).c_str());
if (!listing.empty()) fd = my_open(listing.c_str(), flags);
}
if (fake_path)
free(fake_path);
@@ -1703,6 +1865,10 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
fd = my_openat(dirfd, pathname, flags, mode);
else
fd = my_openat(dirfd, pathname, flags);
if (fd < 0) {
std::string listing = deck_listing_for(note_sys_failure(dirfd, pathname).c_str());
if (!listing.empty()) fd = my_openat(AT_FDCWD, listing.c_str(), flags);
}
if (fake_path)
free(fake_path);
@@ -98,7 +98,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
/** Between onResume and onPause. */
private var resumed = false
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing() } }
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed") } }
/**
* A Deck's controller is there from the moment it boots, and the client looks for it once, as
@@ -108,8 +108,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
* mode, so its interface took a finger as a mouse. Opened as soon as the session settles on a
* Deck instead.
*/
private fun openDeckRing() {
if (SessionState.deckPad) padBridge?.start()
private fun openDeckRing(why: String) {
val deck = SessionState.deckPad
val opened = if (deck) padBridge?.start() else null
Log.i(TAG, "deck ring ($why): deck pad $deck" + when (opened) {
null -> ", left for first input"
true -> ", ring open"
false -> ", ring NOT open"
})
}
/** Motion is read while the session shows and only if the pad is a Deck controller - which
@@ -231,8 +237,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
* press, and a user without one must not be left with no way to answer Big Picture.
*/
private val deviceListener = object : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) = updateOnScreenControls()
override fun onInputDeviceRemoved(deviceId: Int) = updateOnScreenControls()
override fun onInputDeviceAdded(deviceId: Int) {
logInputDevice("connected", deviceId)
updateOnScreenControls()
}
override fun onInputDeviceRemoved(deviceId: Int) {
Log.i(TAG, "input device $deviceId disconnected")
updateOnScreenControls()
}
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
}
@@ -284,7 +296,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
}
SessionState.deckPadListener = deckPadListener
openDeckRing()
openDeckRing("activity created")
// 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 {
@@ -1544,6 +1556,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
/** The mode decides which controls appear; `droiddeck-osc` in Downloads still overrides. */
/** Which controller the session has, so a pad report says what was plugged in and when. */
private fun logInputDevice(what: String, deviceId: Int) {
val d = InputDevice.getDevice(deviceId) ?: return
if (!PadBridge.isFromController(d)) return
Log.i(TAG, String.format(java.util.Locale.ROOT, "controller %s: \"%s\" (%04x:%04x, id %d, sources 0x%x)",
what, d.name, d.vendorId, d.productId, d.id, d.sources))
}
private fun updateOnScreenControls() {
val forced = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-osc")
.takeIf { it.isFile }
@@ -173,7 +173,7 @@ object DeviceReport {
h("Device switch files in Download")
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
"droiddeck-osc", "droiddeck-no-pad", "droiddeck-pad-log",
"droiddeck-osc", "droiddeck-no-pad",
"droiddeck-no-hud", "droiddeck-wlr-renderer")) {
val f = File(Environment.getExternalStorageDirectory(), "Download/$name")
if (f.isFile) k(name, FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)")
@@ -66,6 +66,16 @@ public final class PadBridge {
private volatile Runnable onPlayerInput;
private final java.util.concurrent.atomic.AtomicBoolean playerInputPosted = new java.util.concurrent.atomic.AtomicBoolean();
// What reached the pad over the last stats window, so a report of "the pad does nothing" or
// "touch and the pad don't work together" shows in the app log whether input arrived, from
// which device, and whether it got to the ring. Logged only for a window that had input.
private static final long STATS_WINDOW_MS = 10_000;
private int statButtons, statAxes, statOnScreen, statDropped;
private String statDevice;
private boolean statsScheduled;
private final java.util.Set<Integer> seenDevices = new java.util.HashSet<>();
private int lastDeviceId = Integer.MIN_VALUE;
public PadBridge(File fakeInputDir) {
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
}
@@ -134,6 +144,7 @@ public final class PadBridge {
/** @return true when the event was a pad button and has been consumed. */
public synchronized boolean onKeyEvent(KeyEvent event) {
if (!isFromController(event.getDevice())) return false;
noteDevice(event.getDevice());
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_BUTTON_A: state.press(0, pressed); break;
@@ -161,6 +172,8 @@ public final class PadBridge {
default: return false;
}
if (pressed) notePlayerInput();
statButtons++;
scheduleStats();
publish();
return true;
}
@@ -169,6 +182,9 @@ public final class PadBridge {
public synchronized boolean onMotionEvent(MotionEvent event) {
if (!isFromController(event.getDevice())) return false;
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
noteDevice(event.getDevice());
statAxes++;
scheduleStats();
state.leftX = axis(event, MotionEvent.AXIS_X);
// Android's Y axis grows downwards and evdev's ABS_Y does too, so no flip here: what the
// pad reports as "down" is what the client is told.
@@ -203,6 +219,8 @@ public final class PadBridge {
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
mutation.accept(state);
notePlayerInput();
statOnScreen++;
scheduleStats();
publish();
}
@@ -279,8 +297,39 @@ public final class PadBridge {
publish();
}
/** Per event, so the common case - the same pad as last time - is a single compare. */
private void noteDevice(InputDevice device) {
if (device.getId() == lastDeviceId) return;
lastDeviceId = device.getId();
statDevice = device.getName();
if (seenDevices.add(device.getId())) {
Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); ring %s, deck pad %b",
device.getName(), device.getVendorId(), device.getProductId(), device.getId(), device.getSources(),
open ? "open" : "not open yet", SessionState.getDeckPad()));
}
}
private void scheduleStats() {
if (statsScheduled) return;
statsScheduled = true;
mainHandler.postDelayed(this::logStats, STATS_WINDOW_MS);
}
private synchronized void logStats() {
statsScheduled = false;
Log.i(TAG, "[input] last 10 s: pad " + statButtons + " buttons, " + statAxes + " axis events"
+ (statDevice != null ? " (\"" + statDevice + "\")" : "")
+ ", on-screen " + statOnScreen
+ (statDropped > 0 ? ", " + statDropped + " NOT delivered (ring closed)" : "")
+ "; ring " + (open ? "open" : "closed") + ", deck pad " + SessionState.getDeckPad());
statButtons = statAxes = statOnScreen = statDropped = 0;
}
private void publish() {
if (!open && !start()) return;
if (!open && !start()) {
statDropped++;
return;
}
boolean guide = systemGuidePressed || qamChordActive;
boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed);
if (!guide && !qam) {
@@ -33,21 +33,40 @@ object SessionLogShare {
// session pass yet, and the redactor changes nothing in a line that is already clean.
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
ZipOutputStream(zip.outputStream().buffered()).use { z ->
files.forEach { f ->
z.putNextEntry(ZipEntry(folder.name + "/" + f.relativeTo(folder).path))
if (LogRedactor.isText(f)) {
val w = z.bufferedWriter()
LogRedactor.scrubTo(f, w)
w.flush()
} else {
f.inputStream().use { it.copyTo(z) }
}
z.closeEntry()
}
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f) }
liveSteamLogs(context, folder).forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f) }
}
return zip
}
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
z.putNextEntry(ZipEntry(name))
if (LogRedactor.isText(f)) {
val w = z.bufferedWriter()
LogRedactor.scrubTo(f, w)
w.flush()
} else {
f.inputStream().use { it.copyTo(z) }
}
z.closeEntry()
}
/**
* The client's own logs as they stand, for a session shared while it runs. The session script
* copies them into steam/ only as it exits, so a zip made from the drawer had none - and
* controller.txt (which pad the client opened, the touch mode it set) is what a controller or
* touch report needs most. Only for the running session: an older folder would get this
* session's logs. The same files the script copies; nothing holding credentials is in logs/.
*/
private fun liveSteamLogs(context: Context, folder: File): List<File> {
if (folder != SessionPaths.current() || File(folder, "steam").exists()) return emptyList()
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
return logs.listFiles()?.filter { it.isFile && it.length() <= STEAM_LOG_MAX_BYTES }.orEmpty()
}
/** The client's content and bootstrap logs grow large over months and say nothing about a session. */
private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024
fun shareIntent(context: Context, zip: File): Intent {
val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip)
val send = Intent(Intent.ACTION_SEND)
@@ -299,7 +299,7 @@ class SessionService : Service() {
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
SessionState.deckPad = false
deckBinds = emptyList()
if (controllersOn) addControllerEnvironment(guest, fakeInputDir)
if (controllersOn) addControllerEnvironment(guest, fakeInputDir, sessionDir)
// 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
// default is pixman (software, shm), where a Vulkan program cannot draw at all; those run
@@ -616,7 +616,7 @@ 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) {
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File, sessionDir: 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.
@@ -642,11 +642,15 @@ class SessionService : Service() {
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")
logControllersAtStart()
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")
if (SessionState.deckPad) {
guest.add("FAKE_EVDEV_DECK=1")
guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path)
}
} 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
@@ -659,9 +663,11 @@ class SessionService : Service() {
// 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")
}
// The guest side of the pads (libfakeinput: which pads were opened, the Deck's hidraw and why
// it was refused) in pad.log beside the session's other logs, so it is in every shared zip.
// Setup-time lines only, nothing per input event.
guest.add("FAKE_EVDEV_LOG=1")
guest.add("FAKE_EVDEV_LOG_FILE=" + File(sessionDir, "pad.log").path)
SessionState.fakeInputDir = fakeInputDir
}
@@ -1190,6 +1196,18 @@ class SessionService : Service() {
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, buildNotification())
}
/** The pads attached as the session starts; the activity logs the ones that come and go after. */
private fun logControllersAtStart() {
val pads = ArrayList<String>()
for (id in android.view.InputDevice.getDeviceIds()) {
val d = android.view.InputDevice.getDevice(id) ?: continue
if (!com.droiddeck.launcher.input.PadBridge.isFromController(d)) continue
pads.add(String.format(java.util.Locale.ROOT, "\"%s\" (%04x:%04x)", d.name, d.vendorId, d.productId))
}
Log.i(TAG, "controllers at start: " + (if (pads.isEmpty()) "none" else pads.joinToString(", ")) +
"; presented to the guest as " + if (SessionState.deckPad) "a Steam Deck controller" else "an Xbox 360 controller")
}
companion object {
private const val TAG = "SessionService"
/** proot's tracer: above everything in the guest, under the compositor thread's -8. */
@@ -1219,7 +1237,6 @@ class SessionService : Service() {
private const val STEAM_PICKUP_MS = 1500L
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. */
@@ -23,7 +23,11 @@ import java.io.File
* - `/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.
* which libudev's enumeration leaves a device node out;
* - [listingDir] - stand-ins for the listings of `/sys`, `/sys/class` and `/sys/bus`. An app under
* an enforcing SELinux policy (every retail phone) may not list those directories, and libudev
* abandons its whole scan when it cannot, so the client never reached `hidraw`; libfakeinput
* lists these to the client instead when the real listing is refused (FAKE_DECK_SYSFS_LISTING).
*
* Symlinks are absolute guest paths; proot resolves them inside the session.
*/
@@ -47,6 +51,9 @@ object SteamDeckPad {
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
/** 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")
@@ -89,6 +96,11 @@ object SteamDeckPad {
hidrawClass.mkdirs()
link(File(hidrawClass, NODE), HIDRAW)
// Only the names matter: libudev reads a listing for the subsystems to descend into and
// then opens /sys/class/hidraw itself, which is the bind above.
val listing = listingDir(sessionRoot)
for (name in listOf("sys/bus", "sys/class", "sys/devices", "class/hidraw", "bus")) File(listing, name).mkdirs()
// The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode).
link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW)
@@ -436,6 +436,37 @@ case $mode in
prev=$app
done
) &
# Evidence for touch reports: the touch mode the client asks gamescope for (STEAM_TOUCH_CLICK_MODE,
# set once it has a controller, per focused app) and which app has focus, logged as they change.
# A finger that clicks but won't scroll, or touch that stops once a pad is used, shows here as
# the mode in force at that moment. 4 is touch passed through, 1 a left-click mouse.
[ -z "${BL_DESKTOP:-}" ] && (
# What follows "=" in an xprop line, or $2 for a property that is missing or has no value
# yet ("NAME(CARDINAL) =", which read leaves with nothing after the "=").
xprop_value() {
case $1 in *'not found'*|*=) echo "$2"; return ;; esac
local v=${1#*=}
v=${v# }
echo "${v:-$2}"
}
tmode= app=
xprop -spy -root STEAM_TOUCH_CLICK_MODE GAMESCOPE_FOCUSED_APP 2>/dev/null | while read -r line; do
case $line in
STEAM_TOUCH_CLICK_MODE*)
v=$(xprop_value "$line" unset)
[ "$v" = "$tmode" ] && continue
tmode=$v
echo "== touch mode: $tmode (focused app ${app:-?}) at $(date +%T)"
;;
GAMESCOPE_FOCUSED_APP*)
v=$(xprop_value "$line" none)
[ "$v" = "$app" ] && continue
app=$v
echo "== focused app: $app (touch mode ${tmode:-unset}) at $(date +%T)"
;;
esac
done
) &
# The library beat. Both helpers are whole programs under proot (the compat one a python
# start and a parse of the client's config), and nearly every beat has nothing new for them.
# The seeder marks Proton's prefix template as well as the prefixes, so a new prefix is born