Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx 45e71a14a6 feat: experiment with two local touch controllers beside the Deck pad 2026-10-07 21:15:49 -04:00
xXJsonDeruloXx b0cc2ce5c3 docs: written touch configs are queued for Steam Cloud 2026-10-07 21:05:13 -04:00
xXJsonDeruloXx c24e32f0d7 docs: conclusion and recommendation for Steam's touch controller 2026-10-07 21:04:42 -04:00
xXJsonDeruloXx 5952753e23 feat: experimental udev event injection for hidraw hotplug in the session 2026-10-07 20:55:55 -04:00
xXJsonDeruloXx d3a3015f36 docs: local touch controller drives games through Steam Input 2026-10-07 20:20:28 -04:00
xXJsonDeruloXx 8817d6e1f1 feat: experimental local Mobile Touch controller (0000:11fb) behind FAKE_EVDEV_TOUCHCTL 2026-10-07 20:10:03 -04:00
xXJsonDeruloXx 6a08b46c6c docs: touch controller works over loopback; config format and HID protocol 2026-10-07 19:50:13 -04:00
xXJsonDeruloXx 6e2a235e1b docs: record host-side and Steam Link loopback results 2026-10-07 19:34:31 -04:00
xXJsonDeruloXx 341bb13297 docs: research notes on Steam's touch controller 2026-10-07 19:16:03 -04:00
Kurt Himebauch 9310d19857 fix: backport upstream gamescope fix for games left behind Steam's loading screen (#401) 2026-10-07 15:28:11 -04:00
Kurt Himebauch 4454a6d39a fix: keep Updates channels and status side by side (#397) 2026-10-07 13:48:02 -04:00
MaxsTechReview 453f6f5180 Keep the shortcut settings changed in Steam (#393)
Steam saves a shortcut's Properties to shortcuts.vdf as soon as they
change and keeps its appid. The session rewrote every added game's entry
from the app's list before and after each client run, keeping only the
launch options, so a Target or Start In set in Steam, a new name or icon,
the hidden and overlay switches, the last played time and Steam's sort
name were all put back at the next exit.

droiddeck-steam-shortcuts now merges instead: an entry keeps every field
Steam has, and the app's exe is written only where the app changed it.
What the app last asked for and what Steam then had are recorded beside
shortcuts.vdf (.droiddeck-shortcuts.json). A Target changed in Steam to a
file inside the game's folder becomes the exe chosen in Steam > Settings >
Games, and a later pick there replaces it in Steam, so both show the same
exe. The appid now stays with the folder, so changing the exe no longer
turns the game into a new one with a new prefix, playtime and launch
options. A game whose folder is missing is set aside with its settings
and comes back with them. The exit-time merge runs before Steam's logs
are copied.
2026-10-07 09:32:15 -04:00
MaxsTechReview 6d0484941e Make Games folders Steam libraries and skip the zero-filled reservation (#388)
With Second library on Automatic, every writable Games folder is now a
Steam library: it gets the same private prefix and tool binds as the card,
the app hands the session the list (BL_STEAM_LIBRARIES), and
droiddeck-steam-library registers, relabels and forgets entries from it.
Steam offers them in the install dialog as "<folder> (<volume>)", and
Proton mapping, esync, the redist seeder, desktop entries and the compat
beat read every registered library instead of only the card. Games folder
libraries stop at the fast path's bind budget.

Steam's preallocation on Android's FUSE storage either failed outright
(NTFS answers EPERM, so installs were cancelled as "not enough disk
space") or zero-filled the whole download first (exFAT, minutes for a
small game). After fallocate is refused, the Steam client's ftruncate
extension of its own staging files is reported as done; the writes that
follow fill the file. A 76 MB install to a USB SSD now preallocates
instantly instead of taking about 3.5 minutes.

A library that is not there is left out of the list and forgotten until
it returns, imports whose folder is missing are set aside rather than
offered as broken installs, and the front end refreshes when storage is
mounted or removed.
2026-10-07 09:31:33 -04:00
The412Banner cf47910881 File manager: the Linux system's root under Storage (#392) 2026-10-07 08:12:02 -04:00
The412Banner 4f0f0cf5b1 File manager: the Linux system's root under Storage
A "Linux system" entry in the file manager's Storage list (the rail and the
drive menu) opens the root of the Linux system the sessions run in - the
app's files/linuxfs, the desktop's / - so its files can be browsed, copied
in and out without the desktop. Shown once the runtime is installed; Back
stops at that root like a drive's, and the drive label reads Linux system
anywhere under it. The label is translated in all nine languages.
2026-10-07 08:05:43 -04:00
Kurt Himebauch a78d5cb600 Let Steam draw at the session's full size above 1080p (#391) 2026-10-07 07:51:50 -04:00
47 changed files with 2377 additions and 118 deletions

No files matched your search

+256 -5
View File
@@ -1216,8 +1216,70 @@ struct DeckHidraw {
FakeInputRingHeader *ring = nullptr;
size_t mapping_size = 0;
uint8_t pending_feature = 0;
bool touch = false; // the Mobile Touch experiment below, not the Deck
int touch_index = 0;
};
// ---- EXPERIMENT: Steam's Mobile Touch controller as a local device ----
//
// docs/development/steam-touch-controller.md. Steam Link hands the host its touch controls as a
// remote HID device, 0000:11fb ("Mobile Touch Control"), which Steam's controller code classes as
// k_eControllerType_MobileTouch by its ids alone. FAKE_EVDEV_TOUCHCTL=1 offers the client the same
// ids as a local hidraw node, to see whether it builds the touch controller with no stream. The
// 40-byte input report is read from TOUCHCTL_REPORT_FILE (written by hand while experimenting); every
// output and feature report the client sends is logged and appended to TOUCHCTL_OUT_FILE.
// FAKE_EVDEV_TOUCHCTL=<n> offers n of them: /dev/hidraw17, 18, ... with serials MT-DROIDDECK0001, 0002,
// ..., each reading its own report file (touchctl.report, touchctl1.report, ...).
static constexpr unsigned int TOUCH_HIDRAW_MINOR = 17;
static constexpr int TOUCH_MAX = 4;
static constexpr int TOUCH_REPORT_BYTES = 40;
static constexpr int TOUCH_REPORT_INTERVAL_US = 8000;
static constexpr const char *TOUCH_NAME = "Mobile Touch Control";
static std::string touch_serial(int index) {
char serial[32];
snprintf(serial, sizeof(serial), "MT-DROIDDECK%04d", index + 1);
return serial;
}
static std::string touch_report_file(int index) {
return index == 0 ? std::string("/tmp/touchctl.report") : "/tmp/touchctl" + std::to_string(index) + ".report";
}
static constexpr const char *TOUCHCTL_OUT_FILE = "/tmp/touchctl.out";
// While this file exists the device is unplugged: the open stream ends and new opens fail.
static constexpr const char *TOUCHCTL_OFF_FILE = "/tmp/touchctl.off";
// Generic Desktop / Game Pad: one 40-byte input report, one 64-byte output report, one 64-byte feature report.
static const uint8_t kTouchReportDescriptor[] = {
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01,
0x09, 0x02, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x28, 0x81, 0x02,
0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x91, 0x02,
0x09, 0x04, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0};
static int touchctl_count() {
static int count = -1;
if (count < 0) count = getenv("FAKE_EVDEV_TOUCHCTL") ? std::max(0, std::min(TOUCH_MAX, atoi(getenv("FAKE_EVDEV_TOUCHCTL")))) : 0;
return count;
}
static bool fake_touchctl_enabled() { return touchctl_count() > 0; }
__attribute__((visibility("hidden"))) static void touchctl_note(const char *what, const uint8_t *data, size_t size) {
static std::atomic<unsigned> count{0};
unsigned n = ++count;
char hex[3 * 72 + 1] = {};
size_t shown = std::min<size_t>(size, 72);
for (size_t i = 0; i < shown; i++) snprintf(hex + 3 * i, 4, "%02x ", data[i]);
if (n <= 200 || (n & (n - 1)) == 0) Logger::log("touchctl: %s (%zu bytes): %s\n", what, size, hex);
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
int fd = my_open(TOUCHCTL_OUT_FILE, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644);
if (fd >= 0) {
char line[3 * 72 + 64];
struct timespec now;
clock_gettime(CLOCK_REALTIME, &now);
int length = snprintf(line, sizeof(line), "%ld.%03ld %s %s\n", static_cast<long>(now.tv_sec),
now.tv_nsec / 1000000, what, hex);
syscall(SYS_write, fd, line, length);
syscall(SYS_close, fd);
}
}
// What the Deck has and an Xbox pad does not, after the ring's events: motion (PadMotion) and the
// back grips and trackpads (the second screen's Deck controls, DeckControls), already in the
// Deck's axes and units, under a seqlock (FakeInputWriter.writeMotion / writeDeckControls).
@@ -1307,7 +1369,8 @@ __attribute__((visibility("hidden"))) static bool process_is_steam_client() {
__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",
Logger::log("loaded in the Steam client: deck %s, touchctl %s, ring slot 0 %s\n", fake_deck_enabled() ? "on" : "off",
fake_touchctl_enabled() ? "on" : "off",
get_ring_path_for_slot(0).empty() ? "(none configured)" : get_ring_path_for_slot(0).c_str());
}
@@ -1483,6 +1546,172 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
return pair[0];
}
__attribute__((visibility("hidden"))) static void *touch_report_thread(void *arg) {
auto *holder = static_cast<std::shared_ptr<DeckHidraw> *>(arg);
std::shared_ptr<DeckHidraw> self = *holder;
delete holder;
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
uint8_t last[TOUCH_REPORT_BYTES] = {};
for (;;) {
if (access(TOUCHCTL_OFF_FILE, F_OK) == 0) {
Logger::log("touchctl: unplugged\n");
break;
}
uint8_t report[TOUCH_REPORT_BYTES] = {};
int fd = my_open(touch_report_file(self->touch_index).c_str(), O_RDONLY | O_CLOEXEC);
if (fd >= 0) {
syscall(SYS_read, fd, report, sizeof(report));
syscall(SYS_close, fd);
}
if (memcmp(report, last, sizeof(report))) {
touchctl_note(self->touch_index ? "input#1+" : "input", report, sizeof(report));
memcpy(last, report, sizeof(report));
}
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
errno != EAGAIN && errno != EWOULDBLOCK)
break;
struct timespec interval = {0, TOUCH_REPORT_INTERVAL_US * 1000L};
nanosleep(&interval, nullptr);
}
Logger::log("touchctl: reader closed\n");
syscall(SYS_close, self->peer);
return nullptr;
}
// Which of the experiment's sysfs files the client reads, each once: where its scan stops.
__attribute__((visibility("hidden"))) static void touchctl_trace(int dirfd, const char *pathname) {
if (!fake_touchctl_enabled() || !pathname || !process_is_steam_client()) return;
std::string 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) return;
path.assign(dir, length);
path += '/';
path += pathname;
}
if (path.find("usb2") == std::string::npos && path.find("usb3") == std::string::npos && path.find("hidraw17") == std::string::npos &&
path.find("11FB") == std::string::npos && path.find("240:17") == std::string::npos)
return;
static std::mutex mutex;
static auto *seen = new std::vector<std::string>();
{
std::lock_guard<std::mutex> guard(mutex);
if (seen->size() >= 128 || std::find(seen->begin(), seen->end(), path) != seen->end()) return;
seen->push_back(path);
}
Logger::log("touchctl: client read %s\n", path.c_str());
}
// The index of the experiment's node at pathname, or -1.
__attribute__((visibility("hidden"))) static int touch_hidraw_index(const char *pathname) {
if (!pathname || !fake_touchctl_enabled() || strncmp(pathname, "/dev/hidraw", 11)) return -1;
char *end = nullptr;
long minor = strtol(pathname + 11, &end, 10);
if (!end || *end || minor < TOUCH_HIDRAW_MINOR || minor >= TOUCH_HIDRAW_MINOR + touchctl_count()) return -1;
return static_cast<int>(minor - TOUCH_HIDRAW_MINOR);
}
__attribute__((visibility("hidden"))) static bool is_touch_hidraw_path(const char *pathname) {
return touch_hidraw_index(pathname) >= 0;
}
__attribute__((visibility("hidden"))) static int open_touch_hidraw(int flags, int index) {
if (access(TOUCHCTL_OFF_FILE, F_OK) == 0) {
Logger::log("touchctl: open refused while unplugged\n");
errno = ENOENT;
return -1;
}
if (!process_is_steam_client()) {
errno = ENOENT;
return -1;
}
int pair[2];
if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair) < 0) return -1;
int buffer = TOUCH_REPORT_BYTES * 4;
setsockopt(pair[1], SOL_SOCKET, SO_SNDBUF, &buffer, sizeof(buffer));
if (!(flags & O_CLOEXEC)) fcntl(pair[0], F_SETFD, 0);
if (flags & O_NONBLOCK) fcntl(pair[0], F_SETFL, fcntl(pair[0], F_GETFL) | O_NONBLOCK);
auto touch = std::make_shared<DeckHidraw>();
touch->peer = pair[1];
touch->touch = true;
touch->touch_index = index;
{
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
deck_map()[pair[0]] = touch;
}
pthread_t thread;
auto *arg = new std::shared_ptr<DeckHidraw>(touch);
if (pthread_create(&thread, nullptr, touch_report_thread, arg) != 0) {
delete arg;
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
deck_map().erase(pair[0]);
syscall(SYS_close, pair[0]);
syscall(SYS_close, pair[1]);
errno = ENOMEM;
return -1;
}
pthread_detach(thread);
Logger::log("touchctl: /dev/hidraw%u opened as fd %d\n", TOUCH_HIDRAW_MINOR + index, pair[0]);
return pair[0];
}
static int copy_ioctl_string(ioctl_request_t op, void *argp, const char *value);
__attribute__((visibility("hidden"))) static int ioctl_touch(DeckHidraw &touch, ioctl_request_t op, void *argp) {
if (_IOC_TYPE(op) != 'H') {
errno = ENOTTY;
return -1;
}
size_t size = _IOC_SIZE(op);
switch (_IOC_NR(op)) {
case 0x01:
*static_cast<int *>(argp) = sizeof(kTouchReportDescriptor);
return 0;
case 0x02: {
auto *descriptor = static_cast<uint8_t *>(argp);
uint32_t want;
memcpy(&want, descriptor, sizeof(want));
memcpy(descriptor + 4, kTouchReportDescriptor, std::min<size_t>(want, sizeof(kTouchReportDescriptor)));
return 0;
}
case 0x03: {
struct {
uint32_t bustype;
int16_t vendor;
int16_t product;
} info = {BUS_USB, 0x0000, static_cast<int16_t>(0x11fb)};
memcpy(argp, &info, sizeof(info));
return 0;
}
case 0x04: return copy_ioctl_string(op, argp, TOUCH_NAME);
case 0x05: return copy_ioctl_string(op, argp, ("usb-droiddeck-" + std::to_string(2 + touch.touch_index) + "/input0").c_str());
case 0x08: return copy_ioctl_string(op, argp, touch_serial(touch.touch_index).c_str());
case 0x06: // HIDIOCSFEATURE
touchctl_note("set-feature", static_cast<uint8_t *>(argp), size);
touch.pending_feature = size > 1 ? static_cast<uint8_t *>(argp)[1] : 0;
return static_cast<int>(size);
case 0x07: { // HIDIOCGFEATURE: report 2 is the battery in Steam Link's controller
auto *buf = static_cast<uint8_t *>(argp);
touchctl_note("get-feature", buf, std::min<size_t>(size, 8));
uint8_t id = buf[0];
memset(buf, 0, size);
buf[0] = id;
if (size > 2) {
buf[1] = 2;
buf[2] = 100;
}
return static_cast<int>(std::min<size_t>(size, 18));
}
default:
Logger::log("touchctl: unhandled hidraw ioctl 0x%02x\n", _IOC_NR(op));
errno = EINVAL;
return -1;
}
}
// libudev (systemd's sd-device) accepts a device only where its directory is on sysfs, which it
// checks with fstatfs on the directory it resolved. The Deck's sysfs is the app's own files bound
// into /sys, so those paths are said to be sysfs; nothing else is touched.
@@ -1823,6 +2052,8 @@ EXPORT int open(const char *pathname, int flags, ...) {
if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags);
if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags);
touchctl_trace(AT_FDCWD, pathname);
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags, touch_hidraw_index(pathname));
if (is_withdrawn_pad_path(pathname)) {
errno = ENOENT;
return -1;
@@ -1897,6 +2128,8 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags);
if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags);
touchctl_trace(dirfd, pathname);
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags, touch_hidraw_index(pathname));
if (is_withdrawn_pad_path(pathname)) {
errno = ENOENT;
return -1;
@@ -1957,6 +2190,12 @@ static int fake_stat_path(const char *pathname, S *statbuf, Real real) {
errno = ENOENT;
return -1;
}
if (is_touch_hidraw_path(pathname) && process_is_steam_client()) {
memset(statbuf, 0, sizeof(*statbuf));
statbuf->st_mode = S_IFCHR | 0666;
statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, TOUCH_HIDRAW_MINOR + touch_hidraw_index(pathname));
return 0;
}
if (is_deck_hidraw_path(pathname)) {
memset(statbuf, 0, sizeof(*statbuf));
statbuf->st_mode = S_IFCHR | 0666;
@@ -2008,7 +2247,7 @@ static int fake_stat_fd(int fd, S *buf, Real real) {
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
if (ret == 0 && deck_map().count(fd)) {
buf->st_mode = (buf->st_mode & ~S_IFMT) | S_IFCHR;
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, DECK_HIDRAW_MINOR);
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, deck_map()[fd]->touch ? TOUCH_HIDRAW_MINOR + deck_map()[fd]->touch_index : DECK_HIDRAW_MINOR);
return ret;
}
auto controller = controller_map().find(fd);
@@ -2054,6 +2293,7 @@ EXPORT int access(const char *pathname, int mode) {
static auto my_access = reinterpret_cast<decltype(&::access)>(dlsym(RTLD_NEXT, "access"));
if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0;
if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0;
if (is_touch_hidraw_path(pathname) && process_is_steam_client()) return 0;
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
errno = ENOENT;
return -1;
@@ -2090,6 +2330,7 @@ EXPORT int faccessat(int dirfd, const char *pathname, int mode, int flags) {
static auto my_faccessat = reinterpret_cast<decltype(&::faccessat)>(dlsym(RTLD_NEXT, "faccessat"));
if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0;
if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0;
if (is_touch_hidraw_path(pathname) && process_is_steam_client()) return 0;
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
errno = ENOENT;
return -1;
@@ -2157,6 +2398,8 @@ EXPORT int inotify_add_watch(int fd, const char *pathname, uint32_t mask) {
}
int ret = my_inotify_add_watch(fd, pathname, mask);
if (fake_touchctl_enabled() && process_is_steam_client())
Logger::log("touchctl: client watches %s (mask 0x%x) -> %d\n", pathname ? pathname : "(null)", mask, ret);
if (fake_path)
free(fake_path);
return ret;
@@ -2211,7 +2454,8 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
auto maker = uinput_map().find(fd);
if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp);
auto deck = deck_map().find(fd);
if (deck != deck_map().end()) return ioctl_deck(*deck->second, op, argp);
if (deck != deck_map().end())
return deck->second->touch ? ioctl_touch(*deck->second, op, argp) : ioctl_deck(*deck->second, op, argp);
auto controller = controller_map().find(fd);
if (controller == controller_map().end()) {
guard.unlock();
@@ -2537,7 +2781,10 @@ EXPORT ssize_t write(int fd, const void *buf, size_t count) {
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
auto made = uinput_map().find(fd);
if (made != uinput_map().end()) return write_uinput(*made->second, buf, count);
if (deck_map().count(fd)) return static_cast<ssize_t>(count);
if (deck_map().count(fd)) {
if (deck_map()[fd]->touch) touchctl_note("output", static_cast<const uint8_t *>(buf), count);
return static_cast<ssize_t>(count);
}
auto controller = controller_map().find(fd);
if (controller != controller_map().end()) {
if (fake_fd_is_stale(fd)) {
@@ -2562,7 +2809,11 @@ EXPORT ssize_t writev(int fd, const struct iovec *iov, int iovcnt) {
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
if (deck_map().count(fd)) {
ssize_t total = 0;
for (int i = 0; i < iovcnt; i++) total += static_cast<ssize_t>(iov[i].iov_len);
bool touch = deck_map()[fd]->touch;
for (int i = 0; i < iovcnt; i++) {
if (touch) touchctl_note("output", static_cast<const uint8_t *>(iov[i].iov_base), iov[i].iov_len);
total += static_cast<ssize_t>(iov[i].iov_len);
}
return total;
}
auto made = uinput_map().find(fd);
@@ -175,6 +175,16 @@ class MainActivity : ComponentActivity() {
refreshWifiDiscovery()
}
}
private val mediaRefresh = Runnable {
refresh()
refreshAddedGames()
}
private val mediaReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
ui.removeCallbacks(mediaRefresh)
ui.postDelayed(mediaRefresh, MEDIA_SETTLE_MS)
}
}
private val wifiLocationPermission = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
refreshWifiDiscovery()
@@ -927,6 +937,11 @@ class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
registerReceiver(wifiLocationReceiver, android.content.IntentFilter(android.location.LocationManager.MODE_CHANGED_ACTION))
registerReceiver(mediaReceiver, android.content.IntentFilter().apply {
for (action in listOf(Intent.ACTION_MEDIA_MOUNTED, Intent.ACTION_MEDIA_UNMOUNTED, Intent.ACTION_MEDIA_REMOVED,
Intent.ACTION_MEDIA_EJECT, Intent.ACTION_MEDIA_BAD_REMOVAL)) addAction(action)
addDataScheme("file")
})
displayManager.registerDisplayListener(secondScreenDisplayListener, ui)
refreshSecondScreenDisplays()
}
@@ -938,6 +953,8 @@ class MainActivity : ComponentActivity() {
override fun onStop() {
unregisterReceiver(wifiLocationReceiver)
unregisterReceiver(mediaReceiver)
ui.removeCallbacks(mediaRefresh)
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
// The session covers the page by now; coming back finds it as it was.
flood = null
@@ -1349,7 +1366,7 @@ class MainActivity : ComponentActivity() {
/** Walks the added-games folders, which can sit on slow shared storage or an SD card. */
private fun scanAddedGames() = com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, g.candidates.map { c -> c.path to c.name }.distinctBy { it.first })
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, (g.candidates + g.exe).map { c -> c.path to c.name }.distinctBy { it.first })
}
private fun fetchAddedGameArt() {
@@ -1625,6 +1642,7 @@ class MainActivity : ComponentActivity() {
companion object {
private const val TAG = "MainActivity"
private const val MEDIA_SETTLE_MS = 1500L
/** What the picker offers for a driver zip; some file apps label a zip as a plain stream. */
private val ZIP_EXT = listOf("zip")
private val WCP_EXT = listOf("wcp")
@@ -99,5 +99,10 @@ fun describeLocation(file: File): FavLocation {
}
}
// The Linux system's root (the app's files/linuxfs, the sessions' /) and anything under it.
if (Regex("/files/linuxfs(/|$)").containsMatchIn(abs)) {
return FavLocation(FavStorage.OTHER, R.string.fm_drive_linux, abs)
}
return FavLocation(FavStorage.OTHER, R.string.fm_drive_storage, abs)
}
@@ -42,6 +42,7 @@ import androidx.compose.material.icons.filled.ViewList
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Image
@@ -430,6 +431,9 @@ fun FileManagerScreen(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val drives = remember(storageTick) { StorageRoots.list(context) }
val linuxRoot = remember(storageTick) {
com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context).takeIf { File(it, "usr").isDirectory }
}
// ── Dialogs ──
@@ -690,6 +694,16 @@ fun FileManagerScreen(
},
)
}
linuxRoot?.let { root ->
MenuItemDivider()
DropdownMenuItem(
text = { Text(stringResource(R.string.fm_drive_linux)) },
leadingIcon = {
Icon(Icons.Filled.Terminal, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
},
onClick = { showDriveMenu = false; openDrive(root) },
)
}
}
}
@@ -998,6 +1012,8 @@ fun FileManagerScreen(
showFavorites = false; if (d.readable) openDrive(d.dir)
})
}
// The Linux system the sessions run in: its own / (the desktop's root), once installed.
linuxRoot?.let { add(locItem(stringResource(R.string.fm_drive_linux), Icons.Filled.Terminal, it)) }
}
val quickItems = buildList {
File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_downloads), Icons.Filled.Download, it, stringResource(R.string.fm_rail_downloads_short))) }
@@ -98,7 +98,7 @@ object AddedGames {
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
val steamInstalls = steamInstallDirs(dir)
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
if (folder.name.lowercase() in steamInstalls) continue
if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue
scanGame(context, folder, out)
}
}
@@ -126,32 +126,59 @@ object AddedGames {
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
run {
val candidates = candidates(folder)
val chosen = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
val exe = chosen ?: candidates.firstOrNull() ?: return
val guestExe = guestPath(context, exe)
if (guestExe == null) { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); return }
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
val picked = SessionPrefs.addedGameExe(context, folder.path)
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
val found = chosen ?: candidates.firstOrNull() ?: return
val foundGuest = guestPath(context, found)
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${found.path}"); return }
val name = folder.name
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
val crc = CRC32().apply { update(("\"${guestExe.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
val appId = crc or 0x80000000L
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val steamId = steamRoute(steamRoot, appId)
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
val config = accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))
val record = config?.let { shortcutRecord(it, appId) }
val rev = record?.optInt("rev", 0) ?: 0
val adopted = guestPath(context, folder)
?.let { steamTarget(folder, it, record, foundGuest, SessionPrefs.addedGameExeSeen(context, folder.path)) }
?.takeIf { SessionPrefs.adoptAddedGameExe(context, folder.path, picked, it.path, rev) }
val exe = adopted ?: found
val guestExe = if (adopted != null) guestPath(context, adopted) ?: return else foundGuest
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
val steamId = config?.let { steamRoute(it, appId) }
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
}
}
private fun steamRoute(root: File, appId: Long): Int? = runCatching {
val users = File(root, "config/loginusers.vdf").readText()
val blocks = Regex(""""(\d{5,})"\s*\{([^}]*)\}""")
val recent = blocks.findAll(users).firstOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
?: return@runCatching null
val account = recent.groupValues[1].toLong() - 76561197960265728L
JSONObject(File(root, "userdata/$account/config/.droiddeck-routes.json").readText())
.optInt(appId.toString()).takeIf { it > 0 }
private fun accountConfig(root: File): File? {
val users = runCatching { File(root, "config/loginusers.vdf").readText() }.getOrDefault("")
val recent = Regex(""""(\d{5,})"\s*\{([^}]*)\}""").findAll(users)
.lastOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
val account = recent?.groupValues?.get(1)?.toLongOrNull()?.let { (it - 76561197960265728L).toString() }
?: File(root, "userdata").list()?.filter { it.isNotEmpty() && it.all(Char::isDigit) && it != "0" }?.singleOrNull()
return account?.let { File(root, "userdata/$it/config") }
}
private fun steamRoute(config: File, appId: Long): Int? = runCatching {
JSONObject(File(config, ".droiddeck-routes.json").readText()).optInt(appId.toString()).takeIf { it > 0 }
}.getOrNull()
private fun shortcutRecord(config: File, appId: Long): JSONObject? = runCatching {
JSONObject(File(config, ".droiddeck-shortcuts.json").readText()).optJSONObject("games")?.optJSONObject(appId.toString())
}.getOrNull()
internal fun steamTarget(folder: File, folderGuest: String, record: JSONObject?, guestExe: String, seen: Int): File? {
if (record == null || record.optInt("rev", 0) == seen) return null
val asked = unquote(record.optJSONObject("app")?.optString("Exe"))
val steam = unquote(record.optJSONObject("steam")?.optString("Exe"))
if (asked != guestExe || steam.isEmpty() || steam == asked || !steam.startsWith("$folderGuest/")) return null
val file = File(folder, steam.removePrefix("$folderGuest/"))
val inside = runCatching { file.canonicalPath.startsWith(folder.canonicalPath + File.separator) }.getOrDefault(false)
return file.takeIf { inside && it.isFile }
}
private fun unquote(value: String?): String = value.orEmpty().trim().trim('"')
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
fun writeListing(context: Context, games: List<Game>): File {
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
@@ -161,6 +188,7 @@ object AddedGames {
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
.append(",\"seen\":").append(SessionPrefs.addedGameExeSeen(context, g.folder.path))
// The art, as the session sees it: the app's cache is bound at its own path, a file in
// the game's folder at the folder's guest path.
val art = AddedGameArt.resolve(context, g)
@@ -86,17 +86,20 @@ object Library {
return listOfNotNull(
root to "internal",
GameStorage.effective(context)?.let { File(it.path) to it.label },
)
) + GameStorage.gamesFolderLibraries(context).map { it.host to it.label }
}
/** Proton keeps each game's prefix below compatdata/<appid>/pfx in a Steam library. */
fun protonPrefix(context: Context, appId: Long, preferredLibrary: File? = null): File? {
fun protonPrefix(
context: Context, appId: Long, preferredLibrary: File? = null,
libraries: List<File> = steamLibraries(context).map { it.first },
): File? {
val ids = listOf(appId.toString(), java.lang.Integer.toString(appId.toInt())).distinct()
val secondary = GameStorage.effective(context)?.let { File(it.path) }
fun prefixRoot(root: File): File = if (secondary != null && root.canonicalPath == secondary.canonicalPath)
val secondary = libraries.drop(1).mapNotNull { runCatching { it.canonicalPath }.getOrNull() }.toSet()
fun prefixRoot(root: File): File = if (runCatching { root.canonicalPath }.getOrNull() in secondary)
SecondaryLibrary.privateRoot(context.filesDir, root).takeIf { File(it, "steamapps/compatdata").isDirectory } ?: root
else root
val roots = (listOfNotNull(preferredLibrary) + steamLibraries(context).map { it.first }).map(::prefixRoot)
val roots = (listOfNotNull(preferredLibrary) + libraries).map(::prefixRoot)
.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.absolutePath) }
return roots.asSequence()
.flatMap { root -> ids.asSequence().map { id -> File(root, "steamapps/compatdata/$id/pfx") } }
@@ -107,6 +110,7 @@ object Library {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val cache = File(root, "appcache/librarycache")
val libraries = steamLibraries(context)
val roots = libraries.map { it.first }
val out = LinkedHashMap<Int, SteamGame>()
for ((library, label) in libraries) {
val steamapps = File(library, "steamapps")
@@ -132,7 +136,7 @@ object Library {
out[appId] = SteamGame(
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
gameFiles = gameFiles,
protonPrefix = protonPrefix(context, appId.toLong(), library),
protonPrefix = protonPrefix(context, appId.toLong(), library, roots),
icon = steamCacheImage(cache, appId, listOf("icon.jpg", "icon.png"))
?: File(cache, appId.toString()).listFiles()?.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) },
)
@@ -142,16 +146,19 @@ object Library {
}
/** The same installed-game inventory used for links, shortcuts and file exports. */
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> = (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId),
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
)
}
}).distinctBy { it.gameId }
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> {
val roots = steamLibraries(context).map { it.first }
return (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId, libraries = roots),
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
)
}
}).distinctBy { it.gameId }
}
/** Steam stores current library art inside hash-named folders under the app's cache dir. */
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
@@ -6,6 +6,7 @@ import android.os.StatFs
import android.os.storage.StorageManager
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.frontend.AddedGames
import java.io.File
/**
@@ -65,6 +66,25 @@ object GameStorage {
}
}
class Library(val host: File, val guest: String, val label: String)
fun gamesFolderLibraries(context: Context): List<Library> {
if (SessionPrefs.gameStorage(context).isNotEmpty()) return emptyList()
val card = effective(context)?.let { canonical(File(it.path)) }
val sm = context.getSystemService(StorageManager::class.java)
return AddedGames.roots(context).mapNotNull { root ->
val host = canonical(root.host)
if ('"' in root.guest || '\\' in root.guest || !root.host.isDirectory || !root.host.canWrite()) return@mapNotNull null
if (card != null && (host == card || host.startsWith("$card/") || card.startsWith("$host/"))) return@mapNotNull null
val volume = try { sm?.getStorageVolume(root.host)?.getDescription(context) } catch (e: Exception) { null }
val name = root.host.name.ifEmpty { context.getString(R.string.gstore_folder) }
val label = if (volume.isNullOrBlank()) name else context.getString(R.string.gstore_folder_on_volume, name, volume)
Library(root.host, root.guest, label.replace('"', ' ').replace('\\', ' '))
}
}
private fun canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.absolutePath)
/** The label the client shows for a chosen folder: its last name, or the volume's. */
fun labelFor(context: Context, path: String): String =
options(context).firstOrNull { it.path == path }?.name
@@ -23,8 +23,10 @@ object SecondaryLibrary {
return File(files, "steam-libraries/$digest")
}
val CARD_GUESTS = listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd")
/** Parent binds come first; the more specific private directories override them in PRoot. */
fun binds(files: File, library: File): List<String> {
fun binds(files: File, library: File, guests: List<String> = CARD_GUESTS): List<String> {
val private = privateRoot(files, library)
val apps = File(library, "steamapps")
val tools = LinkedHashMap(bootstrapTools)
@@ -57,7 +59,7 @@ object SecondaryLibrary {
if (lower != relative) overrides.add(target to lower)
}
}
return listOf("/mnt/droiddeck-sd", "/mnt/bannerlator-sd").flatMap { guest ->
return guests.flatMap { guest ->
listOf("${library.path}:$guest") + overrides.map { (host, relative) -> "${host.path}:$guest/$relative" }
}
}
@@ -593,8 +593,25 @@ object SessionPrefs {
/** The .exe the user chose for one game folder (by its path), "" = the scanner's pick. */
fun addedGameExe(context: Context, folderPath: String): String = prefs(context).getString("addedExe:$folderPath", "") ?: ""
private val addedGameLock = Any()
fun setAddedGameExe(context: Context, folderPath: String, path: String) {
prefs(context).edit().putString("addedExe:$folderPath", path).apply()
synchronized(addedGameLock) { prefs(context).edit().putString("addedExe:$folderPath", path).apply() }
}
fun addedGameExeSeen(context: Context, folderPath: String): Int = prefs(context).getInt("addedExeSeen:$folderPath", 0)
fun adoptAddedGameExe(context: Context, folderPath: String, expected: String, path: String, seen: Int): Boolean =
synchronized(addedGameLock) {
val unchanged = addedGameExe(context, folderPath) == expected
if (unchanged) prefs(context).edit().putString("addedExe:$folderPath", path).putInt("addedExeSeen:$folderPath", seen).apply()
unchanged
}
fun addedGameAppId(context: Context, folderPath: String, first: Long): Long = synchronized(addedGameLock) {
val p = prefs(context)
p.getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
?: first.also { p.edit().putLong("addedAppId:$folderPath", it).apply() }
}
/** The app's colour theme (ui/Themes ids); Graphite unless chosen otherwise. */
@@ -426,7 +426,7 @@ class SessionService : Service() {
// whatever a session leaves behind. The client abandons tens of megabytes of streams a run.
FileUtils.clear(File(cacheDir, "shm"))
val binds = sessionBinds(controllersOn, fakeInputDir, sessionDir, guest)
val binds = sessionBinds(controllersOn, fakeInputDir, sessionDir, guest, sessionRoot, runtimeDir)
// The fast path is told exactly the rootfs and binds proot is given (ProotFastPath).
val fastPathKey = if (ProotFastPath.enabled(this)) {
val prootBinds = LinuxRuntime.binds(
@@ -777,6 +777,8 @@ class SessionService : Service() {
fakeInputDir: File,
sessionDir: File,
guest: MutableList<String>,
sessionRoot: File,
runtimeDir: File,
): ArrayList<String> {
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
@@ -897,6 +899,45 @@ class SessionService : Service() {
} else if (roms != null) {
Log.w(TAG, "roms: $roms is not a readable folder; /root/ROMs not offered this session")
}
val libraries = ArrayList<Pair<String, String>>()
if (storageDiagnosticLibrary != null && library != null) libraries.add(SecondaryLibrary.CARD_GUESTS[0] to library.label)
val gamesLibraries = GameStorage.gamesFolderLibraries(this)
if (gamesLibraries.isNotEmpty()) {
var room = if (ProotFastPath.enabled(this)) {
ProotFastPath.MAX_BINDS - LinuxRuntime.binds(this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds).size
} else Int.MAX_VALUE
for (games in gamesLibraries) {
if (games.host.path + ":" + games.guest !in binds) continue
val problem = GameStorage.prepare(this, games.host.path)
if (problem != null) {
Log.w(TAG, "steam library: $problem")
continue
}
try {
val extra = SecondaryLibrary.binds(filesDir, games.host, listOf(games.guest)).drop(1)
if (extra.size > room) {
Log.w(TAG, "steam library: ${games.host} needs ${extra.size} binds, $room left for the fast path; not a library this session")
continue
}
binds.addAll(extra)
room -= extra.size
libraries.add(games.guest to games.label)
Log.i(TAG, "steam library: ${games.host} -> ${games.guest} (\"${games.label}\")")
} catch (e: Exception) {
Log.w(TAG, "steam library: ${games.host} private directories could not be prepared", e)
}
}
}
try {
val listing = File(filesDir, "session/steam-libraries.json").apply { parentFile?.mkdirs() }
val json = org.json.JSONArray()
for ((path, label) in libraries) json.put(org.json.JSONObject().put("path", path).put("label", label.replace('"', ' ').replace('\\', ' ')))
listing.writeText(json.toString())
val envAt = guest.indexOf(LinuxRuntime.SESSION_SCRIPT).takeIf { it >= 0 } ?: guest.size
guest.add(envAt, "BL_STEAM_LIBRARIES=" + listing.path)
} catch (e: Exception) {
Log.w(TAG, "steam library: the list could not be written; the card alone this session", e)
}
return binds
}
@@ -44,6 +44,12 @@ object SteamDeckPad {
private const val HIDRAW = "$HID/hidraw/$NODE"
private const val SERIAL = "DROIDDECK0001"
// EXPERIMENT (docs/development/steam-touch-controller.md): Steam's Mobile Touch controller,
// 0000:11fb, as a second local device. libfakeinput serves the node only with
// FAKE_EVDEV_TOUCHCTL=1 (fakeinput_steam.cpp: TOUCH_HIDRAW_*).
private const val TOUCH_MINOR = 17
private const val TOUCH_DEVICES = 2
/** 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,
@@ -51,6 +57,14 @@ object SteamDeckPad {
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** Must match libfakeinput's kTouchReportDescriptor. */
private val TOUCH_REPORT_DESCRIPTOR = intArrayOf(
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01,
0x09, 0x02, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x28, 0x81, 0x02,
0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x91, 0x02,
0x09, 0x04, 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")
@@ -107,6 +121,42 @@ object SteamDeckPad {
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")
// EXPERIMENT: Mobile Touch controllers beside the Deck, two of them (hidraw17, 18).
for (index in 0 until TOUCH_DEVICES) {
val minor = TOUCH_MINOR + index
val node = "hidraw$minor"
val bus = 2 + index
val usb = "$GUEST_DEVICES/usb$bus"
val iface = "$usb/$bus-1:1.0"
val hid = "$iface/0003:0000:11FB.000${2 + index}"
val hidraw = "$hid/hidraw/$node"
val serial = "MT-DROIDDECK%04d".format(index + 1)
write(usb, "uevent", "DEVTYPE=usb_device\nPRODUCT=0/11fb/0\nTYPE=0/0/0\nBUSNUM=00$bus\nDEVNUM=002\n")
write(usb, "idVendor", "0000\n")
write(usb, "idProduct", "11fb\n")
write(usb, "bcdDevice", "0000\n")
write(usb, "manufacturer", "Valve Software\n")
write(usb, "product", "Mobile Touch Control\n")
write(usb, "serial", "$serial\n")
link(File(dir(usb), "subsystem"), "/sys/bus/usb")
write(iface, "uevent", "DEVTYPE=usb_interface\nPRODUCT=0/11fb/0\nINTERFACE=3/0/0\n")
write(iface, "bInterfaceNumber", "00\n")
write(iface, "bInterfaceClass", "03\n")
link(File(dir(iface), "subsystem"), "/sys/bus/usb")
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=0003:00000000:000011FB\n" +
"HID_NAME=Mobile Touch Control\nHID_PHYS=usb-droiddeck-$bus/input0\n" +
"HID_UNIQ=$serial\nMODALIAS=hid:b0003g0001v00000000p000011FB\n")
File(dir(hid), "report_descriptor").writeBytes(TOUCH_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)
link(File(hidrawClass, node), hidraw)
link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw)
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) {
@@ -115,8 +115,9 @@ internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifie
}
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
// Landscape has the width for channels and status side by side; a narrow pane, or a small
// phone's (where each channel's name and date would be cut to a word), stacks them.
val stacked = LocalNarrowPane.current || maxWidth < 720.dp
// phone's (where each channel's name and date would be cut to a word), stacks them. The
// Thor's top screen leaves about 690dp here and keeps them side by side.
val stacked = LocalNarrowPane.current || maxWidth < 600.dp
Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
if (stacked) {
StatusPanel(s, u, ua, me)
+4 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s: falló el motor</string>
<string name="hud_fg_needs_lossless">%1$s: requiere Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · iniciando</string>
<string name="added_games_import_hint">Una carpeta con una subcarpeta por cada juego de Windows. Los juegos que tienes en Steam usan sus entradas de Steam tras reiniciar Steam; el resto se convierten en accesos directos ajenos a Steam.</string>
<string name="added_games_import_hint">Una carpeta con una subcarpeta por cada juego de Windows. Los juegos que tienes en Steam usan sus entradas de Steam tras reiniciar Steam; el resto se convierten en accesos directos ajenos a Steam. Con Segunda biblioteca en Automático, Steam también puede instalar juegos en esta carpeta.</string>
<string name="second_library_import_hint">Añade una biblioteca de Steam y busca juegos de Windows en ella. Los juegos que tienes usan sus entradas de Steam; el resto se convierten en accesos directos ajenos a Steam.</string>
<string name="added_games_forget_hint">Olvidar quita los accesos directos en la próxima sesión. Los archivos se conservan.</string>
<string name="game_env_title">Entorno del juego</string>
@@ -353,8 +353,8 @@
<string name="mode_wifi_explain_title">¿Activar la detección de Wi-Fi?</string>
<string name="mode_wifi_explain_text">Android exige el permiso de ubicación para leer los nombres de Wi-Fi y detectar redes cercanas. DroidDeck usa este acceso para mostrar esas redes en Steam. No rastreamos tu ubicación. El acceso a internet funciona sin este permiso.</string>
<string name="mode_storage">Almacenamiento de juegos</string>
<string name="mode_storage_auto">Automático - la tarjeta SD si está presente</string>
<string name="mode_storage_auto_none">Automático - la tarjeta SD si está presente (ninguna ahora)</string>
<string name="mode_storage_auto">Automático - la tarjeta SD si está presente, y tus carpetas de juegos</string>
<string name="mode_storage_auto_none">Automático - tus carpetas de juegos, y la tarjeta SD si está presente (ninguna ahora)</string>
<string name="mode_storage_internal">Solo interno</string>
<string name="mode_storage_folder">Carpeta: %1$s</string>
<string name="mode_second_library">Segunda biblioteca</string>
@@ -1401,6 +1401,7 @@
<!-- File manager -->
<string name="fm_drive_internal">Interno</string>
<string name="fm_drive_linux">Sistema Linux</string>
<string name="fm_drive_sd">Tarjeta SD</string>
<string name="fm_drive_storage">Almacenamiento</string>
<string name="fm_select">Seleccionar</string>
+4 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s : échec du moteur</string>
<string name="hud_fg_needs_lossless">%1$s : nécessite Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · Démarrage en cours</string>
<string name="added_games_import_hint">Un dossier contenant plusieurs sous-dossiers, chaque sous-dossier correspondant à un jeu Windows. Les jeux que vous possédez sur Steam utiliseront les entrées Steam correspondantes après le redémarrage de Steam ; les autres jeux deviendront des raccourcis non-Steam.</string>
<string name="added_games_import_hint">Un dossier contenant plusieurs sous-dossiers, chaque sous-dossier correspondant à un jeu Windows. Les jeux que vous possédez sur Steam utiliseront les entrées Steam correspondantes après le redémarrage de Steam ; les autres jeux deviendront des raccourcis non-Steam. Si Bibliothèque secondaire est sur Auto, Steam peut aussi installer des jeux dans ce dossier.</string>
<string name="second_library_import_hint">Ajoutez une bibliothèque Steam et analysez-y les jeux Windows. Les jeux déjà possédés utiliseront les entrées Steam correspondantes, les autres deviendront des raccourcis non-Steam.</string>
<string name="added_games_forget_hint">« Oublier » supprimera ces raccourcis lors de la prochaine session. Mais les fichiers resteront sur le disque.</string>
<string name="game_env_title">Environnement de jeu</string>
@@ -300,8 +300,8 @@
<string name="mode_wifi_explain_title">Activer la découverte Wi-Fi ?</string>
<string name="mode_wifi_explain_text">Android a besoin de l\'autorisation de localisation pour lire les noms Wi-Fi et découvrir les réseaux à proximité. DroidDeck utilise cette autorisation pour afficher ces réseaux dans Steam. Nous ne suivons pas votre position. L\'accès à Internet reste possible sans cette autorisation.</string>
<string name="mode_storage">Stockage des jeux</string>
<string name="mode_storage_auto">Auto - Utiliser la carte SD si disponible</string>
<string name="mode_storage_auto_none">Auto - Utiliser la carte SD si disponible (aucune carte SD actuellement)</string>
<string name="mode_storage_auto">Auto - Utiliser la carte SD si disponible, ainsi que vos dossiers de jeux</string>
<string name="mode_storage_auto_none">Auto - Utiliser vos dossiers de jeux, ainsi que la carte SD si disponible (aucune carte SD actuellement)</string>
<string name="mode_storage_internal">Stockage interne uniquement</string>
<string name="mode_storage_folder">Dossier : %1$s</string>
<string name="mode_second_library">Bibliothèque secondaire</string>
@@ -947,4 +947,5 @@
<string name="game_file_exported">Fichiers du jeu enregistrés dans %1$s</string>
<string name="game_frontend_files">Fichiers frontaux</string>
<string name="fm_up_one_level">Remonter d\'un niveau</string>
<string name="fm_drive_linux">Système Linux</string>
</resources>
+4 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s:エンジンエラー</string>
<string name="hud_fg_needs_lossless">%1$s:Lossless Scalingが必要です</string>
<string name="hud_fg_starting">%1$s %2$s · 起動中</string>
<string name="added_games_import_hint">複数のサブフォルダを含むフォルダ。各サブフォルダはWindowsゲームに対応します。Steamで所有しているゲームはSteamの再起動後にSteamの項目を使用し、それ以外のゲームは非Steamショートカットになります。</string>
<string name="added_games_import_hint">複数のサブフォルダを含むフォルダ。各サブフォルダはWindowsゲームに対応します。Steamで所有しているゲームはSteamの再起動後にSteamの項目を使用し、それ以外のゲームは非Steamショートカットになります。第2ライブラリが自動の場合、Steamはこのフォルダにもゲームをインストールできます。</string>
<string name="second_library_import_hint">Steamライブラリを追加し、その中のWindowsゲームをスキャンします。所有しているゲームはSteamの項目を使用し、それ以外のゲームは非Steamショートカットになります。</string>
<string name="added_games_forget_hint">「忘れる」を選択すると、次回のセッションでこれらのショートカットが削除されます。ただし、ファイルはディスク上に残ります。</string>
<string name="game_env_title">ゲーム環境</string>
@@ -353,8 +353,8 @@
<string name="mode_wifi_explain_title">Wi-Fi検出を有効にしますか?</string>
<string name="mode_wifi_explain_text">AndroidがWi-Fi名を読み取り、付近のネットワークを検出するには位置情報の権限が必要です。DroidDeckはこの権限を使用してSteamにこれらのネットワークを表示します。位置情報を追跡することはありません。この権限がなくてもインターネットにアクセスできます。</string>
<string name="mode_storage">ゲームストレージ</string>
<string name="mode_storage_auto">自動 - SDカードがあればSDカード</string>
<string name="mode_storage_auto_none">自動 - SDカードがあればSDカード(現在なし)</string>
<string name="mode_storage_auto">自動 - SDカードがあればSDカード、およびゲームフォルダ</string>
<string name="mode_storage_auto_none">自動 - ゲームフォルダ、およびSDカードがあればSDカード(現在なし)</string>
<string name="mode_storage_internal">内部ストレージのみ</string>
<string name="mode_storage_folder">フォルダ:%1$s</string>
<string name="mode_second_library">第2ライブラリ</string>
@@ -1377,6 +1377,7 @@
<!-- File manager -->
<string name="fm_drive_internal">内部</string>
<string name="fm_drive_linux">Linux システム</string>
<string name="fm_drive_sd">SDカード</string>
<string name="fm_drive_storage">ストレージ</string>
<string name="fm_select">選択</string>
+4 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s: 엔진 실패</string>
<string name="hud_fg_needs_lossless">%1$s: Lossless Scaling 필요</string>
<string name="hud_fg_starting">%1$s %2$s · 시작 중</string>
<string name="added_games_import_hint">여러 하위 폴더가 포함된 폴더이며, 각 하위 폴더는 Windows 게임에 해당합니다. Steam에서 소유한 게임은 Steam 재시작 후 해당 Steam 항목을 사용하고, 나머지 게임은 비 Steam 바로 가기가 됩니다.</string>
<string name="added_games_import_hint">여러 하위 폴더가 포함된 폴더이며, 각 하위 폴더는 Windows 게임에 해당합니다. Steam에서 소유한 게임은 Steam 재시작 후 해당 Steam 항목을 사용하고, 나머지 게임은 비 Steam 바로 가기가 됩니다. 보조 라이브러리가 자동이면 Steam이 이 폴더에도 게임을 설치할 수 있습니다.</string>
<string name="second_library_import_hint">Steam 라이브러리를 추가하고 그 안의 Windows 게임을 검사합니다. 소유한 게임은 해당 Steam 항목을 사용하고, 나머지 게임은 비 Steam 바로 가기가 됩니다.</string>
<string name="added_games_forget_hint">\"잊기\"를 누르면 다음 세션에서 이 바로 가기가 제거됩니다. 하지만 파일은 디스크에 그대로 남습니다.</string>
<string name="game_env_title">게임 환경</string>
@@ -353,8 +353,8 @@
<string name="mode_wifi_explain_title">Wi-Fi 검색을 활성화하시겠습니까?</string>
<string name="mode_wifi_explain_text">Android는 Wi-Fi 이름을 읽고 주변 네트워크를 검색하려면 위치 권한이 필요합니다. DroidDeck은 이 권한을 사용하여 Steam에 이러한 네트워크를 표시합니다. 위치를 추적하지 않습니다. 이 권한 없이도 인터넷에 액세스할 수 있습니다.</string>
<string name="mode_storage">게임 저장 공간</string>
<string name="mode_storage_auto">자동 - SD 카드가 있으면 SD 카드 사용</string>
<string name="mode_storage_auto_none">자동 - SD 카드가 있으면 SD 카드 사용(현재 SD 카드 없음)</string>
<string name="mode_storage_auto">자동 - SD 카드가 있으면 SD 카드 사용, 게임 폴더도 사용</string>
<string name="mode_storage_auto_none">자동 - 게임 폴더 사용, SD 카드가 있으면 SD 카드도 사용(현재 SD 카드 없음)</string>
<string name="mode_storage_internal">내부 저장 공간만</string>
<string name="mode_storage_folder">폴더: %1$s</string>
<string name="mode_second_library">보조 라이브러리</string>
@@ -1377,6 +1377,7 @@
<!-- File manager -->
<string name="fm_drive_internal">내부</string>
<string name="fm_drive_linux">Linux 시스템</string>
<string name="fm_drive_sd">SD 카드</string>
<string name="fm_drive_storage">저장소</string>
<string name="fm_select">선택</string>
+4 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s: сбой движка</string>
<string name="hud_fg_needs_lossless">%1$s: требуется Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · Запуск</string>
<string name="added_games_import_hint">Папка, содержащая несколько подпапок, каждая из которых соответствует игре Windows. Игры, которыми вы владеете в Steam, будут использовать соответствующие записи Steam после перезапуска Steam, остальные игры станут ярлыками, отличными от Steam.</string>
<string name="added_games_import_hint">Папка, содержащая несколько подпапок, каждая из которых соответствует игре Windows. Игры, которыми вы владеете в Steam, будут использовать соответствующие записи Steam после перезапуска Steam, остальные игры станут ярлыками, отличными от Steam. Если для параметра «Вторая библиотека» выбрано «Авто», Steam также может устанавливать игры в эту папку.</string>
<string name="second_library_import_hint">Добавьте библиотеку Steam и отсканируйте в ней игры Windows. Имеющиеся игры будут использовать соответствующие записи Steam, остальные станут ярлыками, отличными от Steam.</string>
<string name="added_games_forget_hint">«Забыть» удалит эти ярлыки в следующей сессии. Но файлы останутся на диске.</string>
<string name="game_env_title">Игровая среда</string>
@@ -300,8 +300,8 @@
<string name="mode_wifi_explain_title">Включить обнаружение Wi-Fi?</string>
<string name="mode_wifi_explain_text">Android требует разрешение на определение местоположения для чтения имен Wi-Fi и обнаружения ближайших сетей. DroidDeck использует это разрешение для отображения этих сетей в Steam. Мы не отслеживаем ваше местоположение. Доступ в интернет возможен и без этого разрешения.</string>
<string name="mode_storage">Хранилище игр</string>
<string name="mode_storage_auto">Авто - Использовать SD-карту при наличии</string>
<string name="mode_storage_auto_none">Авто - Использовать SD-карту при наличии (сейчас нет SD-карты)</string>
<string name="mode_storage_auto">Авто - Использовать SD-карту при наличии, а также ваши папки с играми</string>
<string name="mode_storage_auto_none">Авто - Использовать ваши папки с играми, а также SD-карту при наличии (сейчас нет SD-карты)</string>
<string name="mode_storage_internal">Только внутреннее хранилище</string>
<string name="mode_storage_folder">Папка: %1$s</string>
<string name="mode_second_library">Вторая библиотека</string>
@@ -955,4 +955,5 @@
<string name="game_file_exported">Файлы игры сохранены в %1$s</string>
<string name="game_frontend_files">Файлы фронтенда</string>
<string name="fm_up_one_level">На уровень вверх</string>
<string name="fm_drive_linux">Система Linux</string>
</resources>
+4 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s:引擎失败</string>
<string name="hud_fg_needs_lossless">%1$s:需要 Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · 启动中</string>
<string name="added_games_import_hint">一个文件夹,其中每个 Windows 游戏各占一个子文件夹。你在 Steam 拥有的游戏会在 Steam 重启后使用其 Steam 条目;其余会变成非 Steam 快捷方式。</string>
<string name="added_games_import_hint">一个文件夹,其中每个 Windows 游戏各占一个子文件夹。你在 Steam 拥有的游戏会在 Steam 重启后使用其 Steam 条目;其余会变成非 Steam 快捷方式。第二个库设为自动时,Steam 也可以把游戏安装到此文件夹。</string>
<string name="second_library_import_hint">添加一个 Steam 库并扫描其中的 Windows 游戏。已拥有的游戏使用其 Steam 条目;其余变成非 Steam 快捷方式。</string>
<string name="added_games_forget_hint">“移除”会在下次会话删除这些快捷方式。文件仍保留在磁盘上。</string>
<string name="game_env_title">游戏环境</string>
@@ -353,8 +353,8 @@
<string name="mode_wifi_explain_title">启用 Wi-Fi 网络发现?</string>
<string name="mode_wifi_explain_text">Android 需要位置权限才能读取 Wi-Fi 名称并发现附近网络。DroidDeck 用此权限在 Steam 中显示这些网络。我们不会跟踪你的位置。没有此权限互联网访问也能正常工作。</string>
<string name="mode_storage">游戏存储</string>
<string name="mode_storage_auto">自动 — 有 SD 卡时使用 SD 卡</string>
<string name="mode_storage_auto_none">自动 — 有 SD 卡时使用 SD 卡(当前没有)</string>
<string name="mode_storage_auto">自动 — 有 SD 卡时使用 SD 卡,以及你的游戏文件夹</string>
<string name="mode_storage_auto_none">自动 — 使用你的游戏文件夹,有 SD 卡时也使用 SD 卡(当前没有)</string>
<string name="mode_storage_internal">仅内部存储</string>
<string name="mode_storage_folder">文件夹:%1$s</string>
<string name="mode_second_library">第二个库</string>
@@ -1377,6 +1377,7 @@
<!-- File manager -->
<string name="fm_drive_internal">内部存储</string>
<string name="fm_drive_linux">Linux 系统</string>
<string name="fm_drive_sd">SD 卡</string>
<string name="fm_drive_storage">存储</string>
<string name="fm_select">选择</string>
+4 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s:引擎故障</string>
<string name="hud_fg_needs_lossless">%1$s:需要 Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · 正在啟動</string>
<string name="added_games_import_hint">一個資料夾,每個 Windows 遊戲各佔一個子資料夾。你在 Steam 上擁有的遊戲會在 Steam 重新啟動後使用其 Steam 項目,其餘遊戲則成為非 Steam 捷徑。</string>
<string name="added_games_import_hint">一個資料夾,每個 Windows 遊戲各佔一個子資料夾。你在 Steam 上擁有的遊戲會在 Steam 重新啟動後使用其 Steam 項目,其餘遊戲則成為非 Steam 捷徑。第二個收藏庫設為自動時,Steam 亦可以將遊戲安裝到此資料夾。</string>
<string name="second_library_import_hint">新增一個 Steam 收藏庫並掃描其中的 Windows 遊戲。已擁有的遊戲會使用其 Steam 項目,其餘遊戲則成為非 Steam 捷徑。</string>
<string name="added_games_forget_hint">「忘記」會在下次會話移除這些捷徑,檔案仍會保留在磁碟上。</string>
<string name="game_env_title">遊戲環境</string>
@@ -353,8 +353,8 @@
<string name="mode_wifi_explain_title">啟用 Wi-Fi 探索?</string>
<string name="mode_wifi_explain_text">Android 需要位置權限才能讀取 Wi-Fi 名稱和尋找附近網絡。DroidDeck 只會用此權限在 Steam 中顯示這些網絡,不會追蹤你的位置。沒有此權限亦可正常上網。</string>
<string name="mode_storage">遊戲儲存位置</string>
<string name="mode_storage_auto">自動 - 有 SD 卡時使用 SD 卡</string>
<string name="mode_storage_auto_none">自動 - 有 SD 卡時使用 SD 卡(目前沒有)</string>
<string name="mode_storage_auto">自動 - 有 SD 卡時使用 SD 卡,以及你的遊戲資料夾</string>
<string name="mode_storage_auto_none">自動 - 使用你的遊戲資料夾,有 SD 卡時亦使用 SD 卡(目前沒有)</string>
<string name="mode_storage_internal">只限內部儲存空間</string>
<string name="mode_storage_folder">資料夾:%1$s</string>
<string name="mode_second_library">第二個收藏庫</string>
@@ -1377,6 +1377,7 @@
<!-- File manager -->
<string name="fm_drive_internal">內部</string>
<string name="fm_drive_linux">Linux 系統</string>
<string name="fm_drive_sd">SD 卡</string>
<string name="fm_drive_storage">儲存空間</string>
<string name="fm_select">選取</string>
+4 -3
View File
@@ -41,7 +41,7 @@
<string name="hud_fg_failed">%1$s:引擎失敗</string>
<string name="hud_fg_needs_lossless">%1$s:需要 Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · 正在啟動</string>
<string name="added_games_import_hint">一個包含多個子資料夾的資料夾,每個子資料夾對應一個 Windows 遊戲。你在 Steam 上擁有的遊戲會在 Steam 重啟後使用對應的 Steam 項目,其餘遊戲會成為非 Steam 捷徑。</string>
<string name="added_games_import_hint">一個包含多個子資料夾的資料夾,每個子資料夾對應一個 Windows 遊戲。你在 Steam 上擁有的遊戲會在 Steam 重啟後使用對應的 Steam 項目,其餘遊戲會成為非 Steam 捷徑。第二收藏庫設為自動時,Steam 也可以將遊戲安裝到此資料夾。</string>
<string name="second_library_import_hint">新增一個 Steam 收藏庫,並掃描其中的 Windows 遊戲。已擁有的遊戲會使用對應的 Steam 項目,其餘遊戲會成為非 Steam 捷徑。</string>
<string name="added_games_forget_hint">「忘記」會在下次工作階段中移除這些捷徑。但檔案仍保留在磁碟上。</string>
<string name="game_env_title">遊戲環境</string>
@@ -353,8 +353,8 @@
<string name="mode_wifi_explain_title">啟用 Wi-Fi 探索?</string>
<string name="mode_wifi_explain_text">Android 需要定位權限才能讀取 Wi-Fi 名稱並探索附近網路。DroidDeck 使用此權限在 Steam 中顯示這些網路。我們不會追蹤你的位置。沒有此權限也可以存取網際網路。</string>
<string name="mode_storage">遊戲儲存空間</string>
<string name="mode_storage_auto">自動 - 有 SD 卡時使用 SD 卡</string>
<string name="mode_storage_auto_none">自動 - 有 SD 卡時使用 SD 卡(目前無 SD 卡)</string>
<string name="mode_storage_auto">自動 - 有 SD 卡時使用 SD 卡,以及你的遊戲資料夾</string>
<string name="mode_storage_auto_none">自動 - 使用你的遊戲資料夾,有 SD 卡時也使用 SD 卡(目前無 SD 卡)</string>
<string name="mode_storage_internal">僅內部儲存空間</string>
<string name="mode_storage_folder">資料夾:%1$s</string>
<string name="mode_second_library">第二收藏庫</string>
@@ -1377,6 +1377,7 @@
<!-- File manager -->
<string name="fm_drive_internal">內部</string>
<string name="fm_drive_linux">Linux 系統</string>
<string name="fm_drive_sd">SD 卡</string>
<string name="fm_drive_storage">儲存空間</string>
<string name="fm_select">選取</string>
+5 -3
View File
@@ -43,7 +43,7 @@
<string name="hud_fg_failed">%1$s: engine failed</string>
<string name="hud_fg_needs_lossless">%1$s: needs Lossless Scaling</string>
<string name="hud_fg_starting">%1$s %2$s · starting</string>
<string name="added_games_import_hint">A folder with one subfolder per Windows game. Games you own on Steam use their Steam entries after Steam restarts; the rest become non-Steam shortcuts.</string>
<string name="added_games_import_hint">A folder with one subfolder per Windows game. Games you own on Steam use their Steam entries after Steam restarts; the rest become non-Steam shortcuts. With Second library on Automatic, Steam can install games into this folder too.</string>
<string name="second_library_import_hint">Adds a Steam library and scans it for Windows games. Owned games use their Steam entries; the rest become non-Steam shortcuts.</string>
<string name="added_games_forget_hint">Forget removes the shortcuts next session. Files stay on disk.</string>
<string name="game_env_title">Game environment</string>
@@ -357,8 +357,8 @@
<string name="mode_wifi_explain_title">Enable Wi-Fi discovery?</string>
<string name="mode_wifi_explain_text">Android requires Location permission to read Wi-Fi names and discover nearby networks. DroidDeck uses this access to show those networks in Steam. We don\'t track your location. Internet access works without this permission.</string>
<string name="mode_storage">Game storage</string>
<string name="mode_storage_auto">Automatic - the SD card when present</string>
<string name="mode_storage_auto_none">Automatic - the SD card when present (none now)</string>
<string name="mode_storage_auto">Automatic - the SD card when present, plus your Games folders</string>
<string name="mode_storage_auto_none">Automatic - your Games folders, plus the SD card when present (none now)</string>
<string name="mode_storage_internal">Internal only</string>
<string name="mode_storage_folder">Folder: %1$s</string>
<string name="mode_second_library">Second library</string>
@@ -1345,6 +1345,7 @@
<string name="content_system_many">Many systems</string>
<!-- File manager -->
<string name="fm_drive_internal">Internal</string>
<string name="fm_drive_linux">Linux system</string>
<string name="fm_drive_sd">SD card</string>
<string name="fm_drive_storage">Storage</string>
<string name="fm_select">Select</string>
@@ -1850,6 +1851,7 @@
<string name="gstore_sd_card">SD card</string>
<!-- %1$s is a size, like 12.3 GB -->
<string name="gstore_folder">Folder</string>
<string name="gstore_folder_on_volume" translatable="false">%1$s (%2$s)</string>
<string name="logshare_chooser">Share session logs</string>
<!-- Stands in for the Steam account name when the client did not record one -->
@@ -0,0 +1,30 @@
package com.droiddeck.launcher.frontend
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class AddedGamesScanTest {
private val app get() = RuntimeEnvironment.getApplication()
@get:Rule val tmp = TemporaryFolder()
@Test fun theSteamLibraryInsideAGamesFolderIsNotAGame() {
val games = tmp.newFolder("PC")
File(games, "Example").mkdirs()
File(games, "Example/Example.exe").writeText("game")
File(games, "steamapps/downloading").mkdirs()
File(games, "steamapps/downloading/Depot.exe").writeText("depot")
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
SessionPrefs.setAddedGamesDirs(app, listOf(games.path))
assertEquals(listOf("Example"), AddedGames.scan(app).map { it.name })
}
}
@@ -0,0 +1,113 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class AddedGamesSteamEditsTest {
@get:Rule val tmp = TemporaryFolder()
private lateinit var context: Context
private lateinit var games: File
private lateinit var folder: File
private lateinit var main: File
private lateinit var other: File
@Before fun setUp() {
context = RuntimeEnvironment.getApplication()
games = tmp.newFolder("PC")
folder = File(games, "Example").apply { mkdirs() }
main = File(folder, "Example.exe").apply { writeBytes(ByteArray(64)) }
other = File(folder, "bin/Other.exe").apply { parentFile!!.mkdirs(); writeBytes(ByteArray(8)) }
SessionPrefs.setGameStorage(context, SessionPrefs.GAME_STORAGE_OFF, "")
SessionPrefs.setAddedGamesDirs(context, listOf(games.path))
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/userdata/123/config").mkdirs()
}
private fun record(appId: Long, asked: String, steam: String, rev: Int) {
val game = JSONObject()
.put("app", JSONObject().put("Exe", "\"$asked\""))
.put("steam", JSONObject().put("Exe", "\"$steam\""))
.put("rev", rev)
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/userdata/123/config/.droiddeck-shortcuts.json")
.writeText(JSONObject().put("games", JSONObject().put(appId.toString(), game)).toString())
}
private fun scanned() = AddedGames.scan(context).single { it.folder == folder }
@Test fun aTargetChangedInSteamBecomesTheAppsChoiceOnce() {
val first = scanned()
assertEquals(main, first.exe)
record(first.appId, "/root/Games/PC/Example/Example.exe", "/root/Games/PC/Example/bin/Other.exe", 1)
val adopted = scanned()
assertEquals(other, adopted.exe)
assertEquals("/root/Games/PC/Example/bin/Other.exe", adopted.guestExe)
assertEquals(first.appId, adopted.appId)
assertEquals(other.path, SessionPrefs.addedGameExe(context, folder.path))
assertEquals(1, SessionPrefs.addedGameExeSeen(context, folder.path))
SessionPrefs.setAddedGameExe(context, folder.path, main.path)
val chosen = scanned()
assertEquals(main, chosen.exe)
assertEquals(first.appId, chosen.appId)
assertEquals(main.path, SessionPrefs.addedGameExe(context, folder.path))
}
@Test fun theListingTellsTheSessionWhichSteamEditTheAppHasTaken() {
val first = scanned()
record(first.appId, "/root/Games/PC/Example/Example.exe", "/root/Games/PC/Example/bin/Other.exe", 3)
val listing = JSONObject(AddedGames.writeListing(context, listOf(scanned())).readText().removePrefix("[").removeSuffix("]"))
assertEquals("/root/Games/PC/Example/bin/Other.exe", listing.getString("exe"))
assertEquals("/root/Games/PC/Example/bin", listing.getString("dir"))
assertEquals(3, listing.getInt("seen"))
assertEquals(first.appId, listing.getLong("appid"))
}
@Test fun theAppidStaysWithTheFolderWhenItsExeChanges() {
val first = scanned()
SessionPrefs.setAddedGameExe(context, folder.path, other.path)
val changed = scanned()
assertEquals(other, changed.exe)
assertEquals(first.appId, changed.appId)
assertEquals(first.gameId, changed.gameId)
}
@Test fun aSteamTargetOutsideTheFolderIsNotTaken() {
val outside = File(games, "Elsewhere/Game.exe").apply { parentFile!!.mkdirs(); writeBytes(ByteArray(8)) }
val first = scanned()
record(first.appId, "/root/Games/PC/Example/Example.exe", "/root/Games/PC/Example/../Elsewhere/Game.exe", 1)
assertEquals(main, scanned().exe)
assertEquals("", SessionPrefs.addedGameExe(context, folder.path))
assertEquals(0, SessionPrefs.addedGameExeSeen(context, folder.path))
assertTrue(outside.isFile)
}
@Test fun steamTargetNeedsANewEditThatStartsFromTheAppsExe() {
val guest = "/root/Games/PC/Example"
fun game(asked: String, steam: String, rev: Int) = JSONObject()
.put("app", JSONObject().put("Exe", "\"$guest/$asked\""))
.put("steam", JSONObject().put("Exe", "\"$guest/$steam\""))
.put("rev", rev)
assertEquals(other, AddedGames.steamTarget(folder, guest, game("Example.exe", "bin/Other.exe", 2), "$guest/Example.exe", 1))
assertNull(AddedGames.steamTarget(folder, guest, game("Example.exe", "bin/Other.exe", 2), "$guest/Example.exe", 2))
assertNull(AddedGames.steamTarget(folder, guest, game("Example.exe", "bin/Other.exe", 2), "$guest/bin/Other.exe", 0))
assertNull(AddedGames.steamTarget(folder, guest, game("Example.exe", "Example.exe", 2), "$guest/Example.exe", 0))
assertNull(AddedGames.steamTarget(folder, guest, game("Example.exe", "Missing.exe", 2), "$guest/Example.exe", 0))
assertNull(AddedGames.steamTarget(folder, guest, null, "$guest/Example.exe", 0))
}
}
@@ -0,0 +1,42 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.frontend.AddedGames
import java.io.File
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class GameStorageTest {
private val app get() = RuntimeEnvironment.getApplication()
@get:Rule val tmp = TemporaryFolder()
@Test fun gamesFoldersBecomeLibrariesOnAutomatic() {
val pc = tmp.newFolder("PC")
val missing = File(tmp.root, "Unplugged")
SessionPrefs.setAddedGamesDirs(app, listOf(pc.path, missing.path))
val libraries = GameStorage.gamesFolderLibraries(app)
assertEquals(listOf(pc), libraries.map { it.host })
assertEquals(listOf(AddedGames.GUEST_DIR + "/PC"), libraries.map { it.guest })
assertEquals(listOf("PC"), libraries.map { it.label })
}
@Test fun aChosenLibraryOrInternalKeepsGamesFoldersPlain() {
SessionPrefs.setAddedGamesDirs(app, listOf(tmp.newFolder("PC").path))
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
SessionPrefs.setGameStorage(app, tmp.newFolder("chosen").path, "Chosen")
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
}
@Test fun namesTheClientCannotStoreAreLeftOut() {
SessionPrefs.setAddedGamesDirs(app, listOf(tmp.newFolder("Quote\"d").path, tmp.newFolder("Back\\slash").path))
assertTrue(GameStorage.gamesFolderLibraries(app).isEmpty())
}
}
@@ -25,6 +25,17 @@ class SecondaryLibraryTest {
}
}
@Test fun aGamesFolderLibraryIsBoundOnlyAtItsOwnPath() {
val files = tmp.newFolder("files")
val library = tmp.newFolder("PC")
val binds = SecondaryLibrary.binds(files, library, listOf("/root/Games/PC"))
val private = SecondaryLibrary.privateRoot(files, library)
assertEquals("${library.path}:/root/Games/PC", binds.first())
assertTrue(binds.contains("$private/steamapps/compatdata:/root/Games/PC/steamapps/compatdata"))
assertTrue(binds.drop(1).all { it.substringAfter(':').startsWith("/root/Games/PC/steamapps/") })
assertFalse(binds.any { it.contains("/mnt/") })
}
@Test fun migrationPreservesSavesLinksAndOriginalAndNeverOverwritesNewSaves() {
val files = tmp.newFolder("files")
val library = tmp.newFolder("card")
+89
View File
@@ -0,0 +1,89 @@
# Steam session focus: how it works and how to debug it
Written 2026-10-07 after the "game opens behind Steam" reports (Skyrim SE, A Plague Tale:
Innocence, Burnout Paradise Remastered), fixed by gamescope patch 0114 (#401). It records what a
game needs to get focus in a Steam session and how to inspect a live session on a device, for the
next report that looks like this.
## How a game gets focus
The Steam session runs two Xwayland servers, as SteamOS does: the client on `:0`, games on `:1`
(`--xwayland-count 2`, `STEAM_MULTIPLE_XWAYLANDS=1` in `droiddeck-session`). Focus is decided in
three steps, and a window can drop out at each one:
1. **gamescope lists candidates.** For each server it collects windows that are mapped, of class
InputOutput, not fully transparent (`_NET_WM_WINDOW_OPACITY` above 0), not an overlay, and
carry an app id (`STEAM_GAME`). It publishes them on `:0`'s root as `GAMESCOPE_FOCUSABLE_APPS`
and `GAMESCOPE_FOCUSABLE_WINDOWS` (window, app id, pid triples). It leaves out of that published
list any window it believes is override-redirect, 1x1, or skip-taskbar-and-pager without
fullscreen.
2. **Steam picks an order.** The client reads those lists and writes
`GAMESCOPECTRL_BASELAYER_APPID`, for example `413091, 489830, 769`. When the running game has
no window in the focusable list, Steam puts itself (769) ahead of it.
3. **gamescope follows Steam.** The virtual connector strategy in a Steam session is
SteamControlled: the first app id in the base layer with a candidate window wins.
gamescope works from its own cached copy of each window's attributes, not from X. A window that X
shows mapped and tagged can still be missing in step 1 if that cache is stale.
## What the bug looked like
- On screen: Steam's black loading screen with only **ABORT GAME**, the game audible underneath.
Resume from the Steam menu does not help.
- `GAMESCOPE_FOCUSED_APP` stays `769`; `GAMESCOPECTRL_BASELAYER_APPID` has `769` ahead of the
game's app id; the game's window is missing from `GAMESCOPE_FOCUSABLE_WINDOWS`.
- On `:1` the game window is mapped, `WM_STATE` Normal, `STEAM_GAME` set, with no opacity,
`_NET_WM_STATE` or overlay properties.
- gamescope's `focus_info` shows server 1's own focus on the game while the global focus is Steam.
Cause: wine creates the game's window override-redirect and makes it managed before mapping it,
which sends no X event. gamescope read the attributes when the window was created and refreshed
`override_redirect` only from ConfigureNotify, so when its read came before wine's change it kept
the window as override-redirect and left it out of the list Steam reads. It is a race: Skyrim
failed about once in six launches on the Thor, on the first launch after a session start. Upstream
fixed it by taking the flag from MapNotify (ValveSoftware/gamescope 3829340, our patch 0114).
The launcher and the game both had window id `0x1c00003` on `:1`: with few clients on that server
the X server hands the game the launcher's freed id. That was not the cause (the game window came
7 s after the launcher's was destroyed), but it makes logs easy to misread.
## Inspecting a live session from adb
Everything below runs on the device as the app's uid (`su 10195` on the Thor, `u0_a195`), using
the rootfs's own binaries through its loader, so nothing has to enter proot:
```sh
R=/data/user/0/com.droiddeck.launcher/files/linuxfs
$R/usr/lib/ld-linux-aarch64.so.1 --library-path $R/usr/lib $R/usr/bin/xprop -display :0 -root \
GAMESCOPE_FOCUSED_APP GAMESCOPE_FOCUSABLE_WINDOWS GAMESCOPECTRL_BASELAYER_APPID
```
- The X servers answer on their **abstract** sockets (`\0/tmp/.X11-unix/X0`, `X1`); xprop finds
them by itself. The socket files under `linuxfs/tmp/.X11-unix` can be stale. Nothing answers
while the session is in the background or the screen is off (the session is stopped).
- The `VAR=… cmd` prefix does not survive `timeout` or `nohup`; put the command in a small
`/system/bin/sh` script in `/data/local/tmp` and run that.
- gamescope's focus state, written to the session's `session.log` (in
`Download/DroidDeck/<date>-NN-steam/`):
```sh
export XDG_RUNTIME_DIR=/data/user/0/com.droiddeck.launcher/files/.wayland-rt WAYLAND_DISPLAY=gamescope-0
G="$R/usr/lib/ld-linux-aarch64.so.1 --library-path $R/usr/lib $R/usr/bin/gamescopectl"
$G log_focus debug; $G focus_info
```
It prints the global focus, input, keyboard and override windows, the connector strategy, and
each server's own focus and keyboard focus. Disagreement between a server's focus and the
global one points at step 1 or 2 above.
- To tell a stale gamescope cache from a real X problem, make gamescope re-read the window: move it
by 1 px and back (ConfigureNotify), or unmap and map it (MapNotify). If the game comes forward,
gamescope's cache was wrong. A restack of a window already on top sends no event and proves
nothing.
- To test gamescope with a candidate binary without rebuilding the apk, put it in the rootfs (for
example `/opt/gstest/gamescope`, owned by the app's uid) and add
`PATH=/opt/gstest:/usr/local/bin:/usr/bin:/bin` to `Download/droiddeck-env`. The session log's
first lines name the gamescope that ran. Remove the line afterwards: the app stages its own
gamescope over `/usr/local/bin` at every session start.
- Never `pkill -f <pattern>` inside `su -c '…'`: the pattern matches that shell's own command line.
Killing `SkyrimSE.exe` that way took the whole session down. Kill by pid.
- Skyrim's menus take adb key events (`input keyevent 20` down, `66` enter); taps do nothing.
+402
View File
@@ -0,0 +1,402 @@
# Steam's touch controller in a DroidDeck session
Research notes, started 2026-10-07. The question: instead of DroidDeck drawing its own on-screen
pad and keeping its own profiles, can the session use the touch controller Steam already has for
the Steam Link app, so that touch layouts are per game, synced to the Steam account and shared
through Steam's community configs?
This page records what the Steam client contains, the candidate approaches, and the experiments
run on the Thor to decide between them; details are in the [Experiment log](#experiment-log).
## Conclusion
**Yes, and without streaming anything.** Steam's touch controller is a Steam Input controller type
(Mobile Touch, `controller_mobile_touch`) that Steam builds for any HID device with the ids
0000:11fb. Steam Link only supplies that device remotely; libfakeinput can supply it locally, the
way it already supplies the Deck pad. Shown on the Thor with an experimental build:
- Steam opens a local `/dev/hidraw17` 0000:11fb, builds the touch controller (type 43, the same
attributes as Steam Link's), loads the game's touch config for it (Valve's touch templates,
official per-game touch configs, the user's own synced ones, community ones), and games get
its input through Steam Input's virtual pad. A touch-controller stick report flew the ship in
Geometry Wars.
- Steam tells the device which app and action set are active (output report 4) and when action
set layers come and go (5, 6), live, plus rumble (1). That is what decides which layout to show.
- Big Picture treats it as a touch controller: the in-game menu shows the touch layout preview,
bindings are edited in Steam's own configurator.
- It can be plugged and unplugged mid-session (injected udev events), so it can step aside when
a physical pad is used, as Steam Link does.
- The layout is part of the config file (`touch_layout`, a `CVirtualControllerLayouts` protobuf),
so it syncs and is shared with the config. Steam loads a touch config written to disk.
**Recommended: approach D below.** DroidDeck draws the controls (from Steam's layout) and sends
the device's 40-byte report; Steam does everything else. It needs no stream, no pairing, no video
and no patching of Steam, and the parts it depends on are the ones Valve has to keep stable for
the Steam Link apps already installed on phones and TVs: the device's ids and report format.
Not settled yet: saving an edited layout goes through a file write rather than a Steam call (Steam
loads the written file and queues it for Steam Cloud like its own autosaves); the overlay must
reproduce Steam Link's default layout and its hiding of unbound controls; touch menus and gyro
were not exercised.
## How Steam Link's touch controller works
Sources: the arm64 client DroidDeck installs (`steam_client_steamdeck_publicbeta_linuxarm64`,
manifest version 1791249696), the protobuf definitions in
[SteamDatabase/Protobufs](https://github.com/SteamDatabase/Protobufs)
(`steam/steammessages_remoteplay.proto`, `steam/steammessages_virtualcontroller.proto`) and Valve's
[touch controller guide](https://steamcommunity.com/app/353380/discussions/4/1735462352489233412).
The work is split between the streaming client (what runs on the phone) and the Steam client being
streamed from (the host):
- **Client: drawing and editing.** `streaming_client` holds `CVirtualController`, built from
`streaming/client/virtualcontroller.cpp`: it draws the controls over the video, runs the layout
editor (tray, colour picker, move/scale), and sends raw touches to the host as
`CInputTouchFingerDownMsg` / `Motion` / `Up` (finger id, x and y normalised to the video).
- **Host: the controller.** `steamui.so` holds the stream server (`CStreamServer`,
`IStreamServerInputDelegate`). `steamclient.so` holds `CMobileTouchControllerAbstraction`, a
Steam Input controller backend next to `CSteamOSHandheldAbstraction` (the Deck),
`CSteamControllerAbstraction` and `CGenericGamepadControllerAbstraction`. Its display name is
"Steam Link Touch Controller" and its config type is `controller_mobile_touch`.
- **Host: the config.** The host owns the touch config and pushes it to the client:
`k_EStreamControlTouchConfigActive` (appid, revision, creator),
`GetTouchConfigData`/`SetTouchConfigData` (config data and layout blobs),
`SaveTouchConfigLayout` (the client sends back an edited layout), `Get`/`SetTouchIconData`,
`TouchActionSetActive` and `TouchActionSetLayerAdded`/`Removed`. Touch configs are Steam Input
configs like any other: per game, saved to the account, publishable. SteamInputDB counted
10,540 community configs for Mobile Touch in March 2026.
Every piece of this is in the arm64 package DroidDeck already installs: the host side in
`steamrtarm64/steamclient.so` and `steamui.so`, and the full renderer and editor in
`steamrtarm64/streaming_client`. The package also carries an `androidarm64/libsteamclient.so`
with the same touch strings.
## Where DroidDeck is today
The session's pad is presented to the Steam client as a Steam Deck controller (28de:1205 through a
served `hidraw` node and a written sysfs, see `SteamDeckPad.kt` and `fakeinput_steam.cpp`), so Deck
configs, per-game bindings and Deck community configs already apply. What Steam's touch controller
would add: control placement, icons, touch menus and on-device layout editing defined by Steam, and
the touch-specific community configs.
## Candidate approaches
| | How | Verdict |
|---|---|---|
| **A. Local Remote Play** | DroidDeck streams from its own Steam client the way the Steam Link app does (pairing, start stream, touch input negotiated). | **Works, not worth it.** The official Steam Link app on the Thor pairs with the session's Steam over loopback and its touch controls drive the session (experiment 2). But the host captures and software-encodes video (libx264, 72-85 % CPU, frames froze), the Steam Link app cannot turn video off, and an input-only client of our own means reimplementing the Remote Play transport and crypto. The protocol does have `enable_video_streaming` in the streaming request. Kept as a fallback only. |
| **B. In-process injection** | Feed touches into the path between the stream server (`steamui.so`) and `CMobileTouchControllerAbstraction` (`steamclient.so`). | **Not needed.** That path is a HID device; D supplies the device instead of patching Steam. |
| **C. Data only** | Read Steam's touch configs and layouts, draw them ourselves, keep feeding the Deck pad. | **Rejected.** Touch configs bind straight to keys, mouse and actions; honouring them would mean reimplementing Steam Input. (D still reads the layout from the config, but Steam does the bindings.) |
| **D. Local Mobile Touch device** | libfakeinput serves 0000:11fb as a local hidraw node; DroidDeck draws Steam's layout and writes the 40-byte report. | **Works (experiments 3, 4). Recommended.** |
## Experiments
1. **Host side alive?** Yes: discovery answers on loopback and Steam Link pairs.
2. **Touch controller over a stream?** Yes, end to end, with Steam Link on the same device.
3. **Loopback cost.** Too high: software video encode (part of 2).
4. **Input without video.** Not run: needs our own Remote Play client; superseded by D.
5. **Valve's renderer as an overlay.** Not run: `streaming_client` draws over decoded video and
lives behind the stream; superseded by D.
6. **Injection points.** The touch controller enters Steam as a HID device (0000:11fb), which is
approach D.
7. **Local 0000:11fb device.** Works: controller built, config loaded, games driven (section 3).
8. **Hotplug.** Works with injected udev events (section 4).
9. **Layout written to disk.** Steam loads it (section 5).
## Experiment log
### 1. Host side alive: yes (2026-10-07, Thor, Steam 1791249696)
- With a Steam session READY, the client listens on TCP 27036 and UDP 27036 (`/proc/net`, app
uid). A discovery probe built from `steammessages_remoteclient_discovery.proto` sent to
`127.0.0.1:27036` from the device gets a `CMsgRemoteClientBroadcastStatus` back: hostname
`DroidDeck`, `enabled_services` 98 (game streaming on), the signed-in user, `steam_deck`
unset, `gaming_device_type` 541.
- The official Steam Link app (Play Store, on the Thor's bottom screen while the session runs on
the top one) finds "DroidDeck" by itself, shows a PIN, and the session's Big Picture shows
**Authorize Device**. After the PIN the app runs its network test and lists DroidDeck as
paired. Pairing to the session's own client over loopback works with no changes.
- Launching Steam Link on display 0 instead puts DroidDeck's session activity into PiP; use
`am start --display 4`. On first start Steam Link sits on its splash until BLUETOOTH_CONNECT
is answered, and the permission dialog does not render on the second screen (granted with
`pm grant`).
### 2. Touch controller over the stream: works end to end
Starting a stream (`streaming_log.txt`, `console_log.txt` in the session's Steam `logs/`):
```
CLIENT: Sending HID device 2020/0112/-1 Xbox Wireless Controller at sdl://3
CLIENT: Sending HID device 0000/11fb/-1 Mobile Touch Control at touch://0
...
Remote Device Found type: 0000 11fb path: touch://0 Product: Mobile Touch Control
!! Steam controller device opened for index 1.
Controller device closed after hid_read failure
Controller 1 disconnected
```
- **The touch controller reaches the host as a remote HID device, 0000:11fb**, opened by the
same controller code that opens the Deck pad, and turned into a virtual controller. The
streaming client is what makes the reports; Steam on the host only sees a HID device.
- Video fails on the host: `PipeWire: Could not connect PipeWire context` (the session runs no
PipeWire), so the app gets audio but a black screen and keeps its loading spinner.
- The app then re-sends only the Thor's built-in pad and drops the touch device; it seems to
withdraw touch controls when a physical controller is present, or until video arrives.
With video fixed (below), **Steam's touch controller runs against the session's own client**:
- Steam Link withholds its touch controls while a physical pad is attached (the Thor's own pad
shows up as "Xbox Wireless Controller"). A four-finger tap opens its stream menu, which has
**Enable Touch Controls** (injected with `sendevent` on `/dev/input/event5`, the bottom panel).
- The client then re-sends `touch://0`; the host opens it as controller type 43, product 0x11fb,
serial `MT-<Steam Link device id>`, capabilities `0000007f83045bff`, creates a virtual controller
for it, and sends the client `TouchConfigActive`, `TouchActionSetActive` and
`SetTouchConfigData`. The app draws Big Picture's touch layout (d-pad, ABXY, Steam button,
keyboard, `...`).
- Touching the controls drives the session: the touch Steam button opens Big Picture's main
menu on the Thor's top screen, touch B closes it, and the session's glyphs switch to the touch
controller's. (D-pad taps from `input tap` did not register; not chased.)
- Cost: the host encodes with libx264 in software (`/dev/video-enc0` missing), 4 threads,
1240x698. CPU sat at 72-85 % in the overlay and `k_EStreamControlVideoOverflow` flooded the log;
the app's picture froze within a minute while input kept working. **Video loopback is not a
shippable path**; only the input half of the stream is useful.
Video fix used for this test only: PipeWire and WirePlumber are in the rootfs and gamescope is
built with PipeWire, so a `gamescope` wrapper on `PATH` (via `Download/droiddeck-env`) that starts
`pipewire` and `wireplumber` first gives gamescope a capture node (`stream available on node ID`)
and the client a picture. (Editing `droiddeck-session` on the device does not stick: the app
re-syncs the overlay each session.)
### Where Steam keeps touch configs
In the session's Steam, `steamapps/common/Steam Controller Configs/<account id>/config/`:
- `<appid>/controller_mobile_touch.vdf` for each game with a saved touch config (dozens already
there for this account, synced down from Steam Cloud), plus named copies like
`<appid>/default touch_0.vdf`;
- `configset_controller_mobile_touch.vdf`: which config each app uses (`autosave` = the per-app
file above, otherwise a template or workshop id);
- `configset_MT-<serial>.vdf`, `preferences_MT-<serial>.vdf` and `config/MT-<serial>_gyro.vdf`:
per touch device.
A touch config is an ordinary `controller_mappings` VDF (`controller_type` `controller_mobile_touch`,
bindings per action set and layer) with one extra key, **`touch_layout`: the on-screen layout as
a hex-encoded `CVirtualControllerLayouts` protobuf**: one layout per action set, each a list of
elements (`EControllerElementType`, visible, x/y position normalised to the screen, x/y scale)
and a colour, plus input mode, mouse mode and pinch-zoom settings. Bindings and layout travel
together in the one file that syncs and is shared as a community config.
### The touch device's HID protocol
From Steam Link for Android (`libmain.so`, exported symbols; `CVirtualController` is both the
renderer and the HID device behind `touch://0`):
- **Input report: 40 bytes** (`CVirtualController::Read`), kept at `this+200` and marked dirty
at `this+240` by every setter. Bytes 0-7: button/touch bits (e.g. bit 27, 19, 20: trackpads
0, 1, 2 touched). Bytes 16, 20, 24: trackpads 0-2 as int16 x (centred, `x*65535 ^ 0x8000`) and
y (inverted). Remaining fields (sticks, triggers, gyro) not yet mapped.
`CHIDDeviceReportGenerator::BInjectGamepadStateMobileTouch` is a stub; the report is the
controller's own format, not the generic SDL gamepad state.
- **Output reports from Steam** (`Write` → `QueueFeatureReport` → `HandleFeatureReports`),
at least 17 bytes, first byte the type:
- 1: rumble (u16 low, u16 high, u32 duration);
- 3: setting `0x30` with a u16 value (a flag at `this+1488`, likely gyro);
- **4: action set change** (u32 appid, u32 action set id): switches the layout shown;
- **5 / 6: action set layer added / removed** (u32 appid, u32 layer id).
- **Feature report 2**: battery (level, from `SDL_GetPowerInfo`), 17 bytes.
So Steam drives action sets, layers and rumble over HID. The only things that come solely over
the stream are the config and layout blobs (`SetTouchConfigData`), the active config's
appid/revision/creator (`TouchConfigActive`) and custom icons (`SetTouchIconData`); the first two
are on disk in the VDF above.
### 3. Without a stream: Steam takes a local 0000:11fb device (works)
Experimental build on this branch: `FAKE_EVDEV_TOUCHCTL=1` (in `Download/droiddeck-env`) makes
libfakeinput serve `/dev/hidraw17` as 0000:11fb "Mobile Touch Control" next to the Deck pad, with
its sysfs written by `SteamDeckPad.prepare` (a second USB device, `usb2`). The 40-byte input report
is read from `/tmp/touchctl.report` in the session; every output and feature report the client
sends is logged to `pad.log` and appended to `/tmp/touchctl.out`.
- With a vendor-page report descriptor (`06 ff ff 09 01`, as the Deck's) the client walks the
whole sysfs tree, reads the descriptor and never opens the node. **With Generic Desktop / Game
Pad (`05 01 09 05`) it opens it**: for a non-Valve vendor id the client wants a gamepad usage.
- Steam then builds the touch controller exactly as it does for Steam Link:
```
Local Device Found type: 0000 11fb path: /dev/hidraw17 serial_number: MT-DROIDDECK0001
!! Controller 0 attributes: Type: 43 ProductID: 4603 Serial: MT-MT-DROIDDECK0001
Capabilities: 0000007f83045bff
```
and speaks the touch protocol to it: `get-feature 02` (battery), output `03 30 ..` (the setting
report), and **output `04 01 03 00 00 01 00 00 00`: action set 1 of app 769** (Big Picture).
- Writing button bits into the report drives the client: Steam (`0x2000`) opens Big Picture's
main menu, B (`0x20`) closes it. Opening the menu made Steam send `04` with app 443510 (the
Steam menu's own touch config; it is one of the app folders in the config directory) and
closing it `04` with 769 / 1 again: **the active app and action set arrive over HID, live.**
Button bits (Steam Link's `CVirtualController` table at `0x463e70`, indexed by
`EControllerElementType - 1`), the same layout as the Deck report's low word:
| Element | Bit | | Element | Bit |
|---|---|---|---|---|
| A | 7 | | Select | 12 |
| B | 5 | | Steam | 13 |
| X | 6 | | Start | 14 |
| Y | 4 | | Left stick click | 22 |
| Left bumper | 3 | | Right stick click | 26 |
| Right bumper | 2 | | Macro 0-7 | 32-39 |
| Left trigger | 1 | | 1-finger / 2-finger macro | 48, 49 |
| Right trigger | 0 | | Trackpad 0 / 1 / 2 touched | 27 / 19 / 20 |
Report layout, all little-endian (Steam Link `CVirtualController`: `TouchControl_JoyButton`,
`TouchControl_JoyStick`, `SetTrackpad`, `UpdateControllerState`):
| Bytes | Field |
|---|---|
| 0-7 | u64 buttons and touch flags (table above; d-pad as the Deck's: up 8, right 9, left 10, down 11; left / right stick touched 46 / 47) |
| 8-15 | int16 left X, left Y, right X, right Y: `x * 32767`, Y stored inverted (`~(y * 32767)`) |
| 16-27 | three trackpads, int16 x (`x * 65535 ^ 0x8000`) and y (`0x7fff - y * 65535`) |
| 28-33 | accelerometer xyz, int16, ±2 g (19.61 m/s²) to ±32767 |
| 34-39 | gyro xyz, int16 |
**Games, through Steam Input, with no stream.** With the session started on Geometry Wars:
Retro Evolved (8400) and the game launched by pressing A twice on the local device, the client
loaded `controller_base/templates/controller_mobile_touch_gamepad_joystick.vdf` for app 8400 on the
touch controller (Valve's stock touch template), told the device `04 d0 20 00 00 ...` (app 8400),
and a left-stick report flew the ship across the screen. Steam's own path end to end:
DroidDeck → `/dev/hidraw17` → Mobile Touch controller → the game's touch config → Steam Input's
virtual Xbox pad → the game. D-pad bits navigated the Steam menu the same way.
What the stream gives that the local device does not:
- **The active config and its layout.** Steam Link asks for it (`GetTouchConfigData`) and the host
answers with the config and the layout. Locally the device is told only the app id and action
set (report 4), so the overlay has to resolve the config itself: `configset_controller_mobile_touch.vdf`
per app (`autosave` = `<appid>/controller_mobile_touch.vdf`, otherwise a template or workshop
config), and the console log line `Loaded Config for ... App ID <n>, Controller <i>: <path>`
names the file Steam actually loaded. Stock templates carry no `touch_layout`; Steam Link draws
its built-in default layout for them (`CVirtualController::ResetLayoutToDefault`), hiding
elements the config leaves unbound (`UpdateElementAvailability`).
- **Saving an edited layout.** Steam Link sends `SaveTouchConfigLayout`; the host writes it into
the app's autosave config, which is what syncs. The local device has no such message. Big
Picture's `SteamClient.Input` API (seen in `steamui/sp.js`) has the config editing calls
(`StartEditingControllerConfigurationForAppIDAndControllerIndex`,
`SetEditingControllerConfigurationMiscSetting`, `SaveEditingControllerConfiguration`,
`GetConfigForAppAndController`, `QueryControllerConfigsForApp`, `SetSelectedConfigForApp`,
`GetTouchMenuIconsForApp`, `RegisterForTouchMenuMessages`) but no layout call; whether
`touch_layout` goes through the misc-setting call is untested. The session's devtools port only
admits guest processes (`BL_CDP_GUARD`), so this needs the agent bridge's guest commands rather
than a probe from adb.
- **Custom icons** (`SetTouchIconData`).
Everything else (bindings, action sets, layers, touch menus, rumble, the configurator, community
configs, cloud sync) is Steam's own and works with the local device.
### 4. Two controllers, and hotplug
With the touch device present beside the Deck pad, Steam creates a virtual pad for each
(`Created virtual controller at slot 0 for controller 0` / `slot 1 for controller 1`): a game
sees two players. Steam Link avoids this by withdrawing its touch controls while a physical pad is
in use, and a local device has to do the same, which needs hotplug.
- **Unplug works as is.** Ending the device's report stream (`/tmp/touchctl.off` in the experiment)
gives `Controller device closed after hid_read failure`, `Controller 0 disconnected`, and the
virtual pad is destroyed. Steam re-enumerates at once, and an open refused then keeps it off.
- **Plugging back in needs an event.** The client watches only `/dev/input` with inotify (already
redirected by libfakeinput) and never rescans hidraw on its own; an inotify event in
`/dev/input` did not trigger a HID rescan. Its HID discovery is SDL's, driven by a udev monitor,
and the session's monitor is `udevmon.c`'s silent stand-in (the sandbox refuses netlink).
- **Injected udev events work.** The experiment extends the stand-in: a file in `/tmp/udev-inject`
(`ACTION`, `DEVPATH`, `SUBSYSTEM=hidraw`, `DEVNAME`, `MAJOR`, `MINOR`) is sent by every process
with a stand-in as udevd's message format (`libudev` header, magic `0xfeedcafe`, properties plus
`SEQNUM`), and the stand-in's `recvmsg` (through `ntsync.c`, which owns the symbol) reports the
udev multicast group as the sender and root credentials, as libudev requires. An injected `add`
for `hidraw17` made the client open the node again within a second and rebuild the touch
controller (type 43) and its virtual pad, mid-session. (The sender address has to be written
from the room `msg_namelen` had before the call; a unix socket sets it to 0.)
So DroidDeck can show and hide Steam's touch controller the way Steam Link does: plug the device
in when the on-screen controls are shown, unplug it when a physical pad takes over.
### 5. Configs and layouts written by DroidDeck
With the session stopped, a `config/8400/controller_mobile_touch.vdf` was written (Valve's gamepad
template plus a `touch_layout` taken from another config) and `"8400" { "autosave" "1" }` added to
`configset_controller_mobile_touch.vdf`. Launching the game:
```
Loaded Config for Local Selection Path for App ID 8400, Controller 0:
.../Steam Controller Configs/392297941/config/8400/controller_mobile_touch.vdf
```
Steam used it as the game's touch config and left the file (and its `touch_layout`) as written.
**Steam Cloud picked it up too**: Steam Controller Configs sync as app 241100's cloud files, and
`userdata/<account>/241100/remotecache.vdf` gained an entry for
`392297941/config/8400/controller_mobile_touch.vdf` (`syncstate` 3, `remotetime` 0: queued for
upload), the same bookkeeping as the account's other touch configs. Both files were put back
afterwards.
Also seen: pressing A on the touch device logged `Seating controller 1 in slot 0`. Steam moves the
controller in use to the first player slot, which softens the two-controller problem even before
the device is unplugged.
### 6. Steam Link's default layout
Stock templates carry no `touch_layout`. Steam Link then places each control from a built-in table
(`CVirtualController::BInitializeDefaultElement`, table at `0x463d48`, positions on a 1280x720
reference):
| Element | Position (of 1280x720) |
|---|---|
| Thumb (menu) | 75, 75 |
| Select / Steam / Start | 516, 75 / 636, 75 / 756, 75 |
| Paste / Keyboard | 1115, 75 / 1205, 75 |
| Magnifying glass / record / playback | 1205, 165 / 255 / 345 |
| D-pad | 200, 525 |
| X / Y / B / A | 1003, 527 / 1083, 447 / 1163, 527 / 1083, 607 |
Sticks, triggers, bumpers and macros are not in the table; they appear when the config binds them
(`UpdateElementAvailability`), and the overlay has to reproduce that rule.
## What building D would take
- **libfakeinput:** the 0000:11fb node as in the experiment (Generic Desktop / Game Pad report
descriptor, 40-byte input reports, output reports 1, 3-6 and feature report 2), fed from a
ring the app writes, like the Deck pad's.
- **SteamDeckPad.kt:** the second device's sysfs, as in the experiment.
- **udevmon.c:** event injection for plug and unplug, with a proper channel from the app instead of
the experiment's file drop.
- **The overlay (Kotlin):** read the active config (configset per app, else the template Steam
logged loading), decode `touch_layout` (or the default table above), show the elements of the
action set Steam names in report 4 and the layers in 5/6, hit-test touches into the report
(buttons, sticks, trackpads; motion from PadMotion into the gyro fields), rumble from report 1.
Hide the unbound controls as Steam Link does.
- **Editing:** move/scale in the overlay, saved by writing `touch_layout` into the app's autosave
config, as Steam does when Steam Link saves a layout.
- **Policy:** plug the device in while the on-screen controls are up and no physical pad is in use;
unplug it otherwise.
The experimental pieces on this branch (`FAKE_EVDEV_TOUCHCTL`, `/tmp/touchctl.*`,
`/tmp/udev-inject`) are research scaffolding to be replaced, not shipped.
### 7. Several controllers
`FAKE_EVDEV_TOUCHCTL=2` serves two touch devices (`/dev/hidraw17` and `18`, serials
`MT-DROIDDECK0001` and `0002`, each with its own sysfs `usb2`/`usb3` and report file). With the
Deck pad that is three controllers, and Steam takes all three:
```
Controller 15 Type 4 (Deck pad, /dev/hidraw16)
Controller 0 Type 43 Serial MT-MT-DROIDDECK0001
Controller 1 Type 43 Serial MT-MT-DROIDDECK0002
Created virtual controller at slot 0 / 1 / 2
uinput: "Microsoft X-Box 360 pad 0" / "pad 1" / "pad 2" is /dev/input/event16 / 17 / 18
```
Each gets its own Steam Input config and its own virtual pad, so a game sees three players; a
Steam press on the second device alone opened the Steam menu (Steam answered it with report 4 for
the menu's action set). Several touch controllers would allow, say, a second player on the Thor's
bottom screen. The other physical pads DroidDeck passes through (ring slots 1-3 as evdev pads) sit
beside these as ordinary gamepads; no external pad was connected for this test.
+6
View File
@@ -38,6 +38,12 @@ library list, before anything is published.
iconify request is remembered and the window goes back to NormalState before input returns to it,
then focus is handed over again. `GAMESCOPE_RESTORE_FOCUS_WINDOW` on the root window asks for the
same restore from outside (the session script's resume watcher).
- `0114-take-override-redirect-from-mapnotify.patch` - upstream (ValveSoftware/gamescope 3829340),
verbatim; drop it once the runtime's gamescope includes it. Wine creates a game's window
override-redirect and makes it managed before mapping it, which sends no X event. When gamescope
read the window's attributes first, it kept the window as override-redirect and left it out of
`GAMESCOPE_FOCUSABLE_WINDOWS`, so the client kept its loading screen over the running game
(Skyrim SE, A Plague Tale: Innocence, on the games' own Xwayland). MapNotify now refreshes the flag.
Sixteen more of Armada's patches are DRM/lease/HDR-on-KMS work for a native display, which this
app's Wayland-hosted gamescope never reaches, or need a newer gamescope than the runtime has.
@@ -0,0 +1,35 @@
From: Matthew Schwartz <[email protected]>
Subject: [PATCH] steamcompmgr: take override-redirect from MapNotify
Wine creates a toplevel as override-redirect and flips it to managed with
XChangeWindowAttributes while it is unmapped, which generates no X event.
When add_win reads the attributes before the flip, the window stays cached
as override-redirect and is left out of GAMESCOPE_FOCUSABLE_APPS. Steam
then keeps its loading screen over the game, as with A Plague Tale:
Innocence (752590). Before 2238420d, the focus nudge's move usually
refreshed the flag.
MapNotify carries the current override-redirect state, so refresh the
cached flag there rather than re-reading the attributes.
Closes: #2457
---
src/steamcompmgr.cpp | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index f00b62c..cb3b9c6 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -8778,7 +8778,11 @@ void xwayland_ctx_t::Dispatch()
steamcompmgr_win_t * w = find_win(ctx, ev.xmap.window);
if (w && w->xwayland().id == ev.xmap.window)
+ {
+ // Wine flips override-redirect while unmapped and the flip itself sends no event.
+ w->xwayland().a.override_redirect = ev.xmap.override_redirect;
map_win(ctx, ev.xmap.window, ev.xmap.serial);
+ }
break;
}
case UnmapNotify:
+2 -2
View File
@@ -1,3 +1,3 @@
GAMESCOPE_REPO=Droid-Deck/DroidDeck-Components
GAMESCOPE_TAG=gamescope-3.16.29-p8
GAMESCOPE_SHA256=65fcc17af1aee4944a42ce1e688db61ceb10db5f299f0de75e338f56646e2953
GAMESCOPE_TAG=gamescope-3.16.29-p9
GAMESCOPE_SHA256=7123a37bdbcd0d76d4bd1d7490e5d2017221e036395fdd48c1e92458594ca769
@@ -73,11 +73,19 @@ def client_entries():
return found
def registered():
try:
with open(os.path.join(STEAM_ROOT, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f:
return [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', f.read())]
except OSError:
return []
def write_entries():
os.makedirs(APPS_DIR, exist_ok=True)
wanted = {}
theirs = client_entries()
for library, _ in LIBRARIES:
for library in dict.fromkeys([library for library, _ in LIBRARIES] + registered()):
for appid, name in games(library):
if appid not in theirs:
wanted.setdefault(appid, name)
@@ -954,8 +954,19 @@ def choose(root, command, env, tool, stock):
return command, env
def libraries(steam_root):
found = [steam_root]
try:
with open(os.path.join(steam_root, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f:
found += [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', f.read())]
except OSError:
pass
found.append(SD_LIBRARY)
return list(dict.fromkeys(found))
def find_tools(steam_root):
candidates = [os.path.join(library, "steamapps", "common", name) for library in (steam_root, SD_LIBRARY) for name in SOURCES]
candidates = [os.path.join(library, "steamapps", "common", name) for library in libraries(steam_root) for name in SOURCES]
folder = os.path.join(steam_root, "compatibilitytools.d")
try:
names = sorted(os.listdir(folder))
@@ -72,6 +72,7 @@ cache="$steam_root/.droiddeck-game-scripts"
libraries="$steam_root"
for library in $LIBRARIES; do libraries="$libraries
$library"; done
libraries=$(printf '%s\n%s\n' "$libraries" "$(sed -n 's/^[[:space:]]*"path"[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p' "$steam_root/steamapps/libraryfolders.vdf" 2>/dev/null)" | awk 'NF && !seen[$0]++')
sig=$(while IFS= read -r library; do ls -l --time-style=+%s "$library"/steamapps/appmanifest_*.acf 2>/dev/null; done <<<"$libraries" | cksum)
if [ "$(head -n 1 "$cache" 2>/dev/null)" != "$sig" ]; then
{
@@ -133,9 +134,12 @@ $(vcrt "$(date +%s)")"
# prefix moments later, so the template carries the markers too. Its date is kept: Proton reads a
# changed one as a new template and upgrades every prefix made from the old.
for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
/mnt/droiddeck-sd/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
/root/Games/*/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
"$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg \
"$steam_root"/steamapps/compatdata/*/pfx/system.reg \
/mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg; do
/mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg \
/root/Games/*/steamapps/compatdata/*/pfx/system.reg; do
[ -f "$reg" ] || continue
seeded=$(dirname "$reg")/.droiddeck-redists
legacy=$(dirname "$reg")/.bannerlator-redists
@@ -568,7 +568,7 @@ case $mode in
local d
[ -e "$compat_stamp" ] && [ "$((beat % 12))" -ne 0 ] || return 0
for d in "$steam_root/steamapps" "$steam_root/steamapps/common" "$steam_root/config" \
"$steam_root/compatibilitytools.d" /mnt/droiddeck-sd/steamapps; do
"$steam_root/compatibilitytools.d" /mnt/droiddeck-sd/steamapps /root/Games/*/steamapps; do
[ "$d" -nt "$compat_stamp" ] && return 0
done
return 1
@@ -839,13 +839,13 @@ BUSCONF
-cef-use-gl=angle -cef-use-angle=vulkan "$@"
rc=$?
echo "== steam exited rc=$rc"
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
fi
collect_steam_logs
if [ -f "$HOME/.config/droiddeck/fex-wanted" ] && [ -x /usr/local/bin/droiddeck-fex ]; then
/usr/local/bin/droiddeck-fex status >/dev/null 2>&1 || true
fi
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
fi
if [ -f "$HOME/.bl-stop-requested" ]; then
rm -f "$HOME/.bl-stop-requested" "$HOME/.bl-restart-once"
break
@@ -16,6 +16,7 @@ STATE = ".droiddeck-games.json"
WINNATIVE = ".winnative-steam/steamapps"
LIBRARY = "/mnt/droiddeck-sd"
LEGACY_LIBRARY = "/mnt/bannerlator-sd"
PARKED = ".droiddeck-parked"
BASE_ID = 76561197960265728
@@ -282,6 +283,42 @@ def update_not_started(text, steamapps, appid, installdir):
and not (steamapps / "downloading" / str(appid)).exists())
def libraries(steam_root):
try:
text = (Path(steam_root) / "steamapps/libraryfolders.vdf").read_text(errors="replace")
except OSError:
text = ""
paths = [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', text)]
return list(dict.fromkeys([Path(steam_root), Path(LIBRARY)] + [Path(path) for path in paths]))
def ours(manifest, appid):
try:
return re.search(rf'"installdir"\s*"DroidDeck-{appid}"', manifest.read_text(errors="replace")) is not None
except OSError:
return False
def unpark(steamapps):
for manifest in sorted((steamapps / PARKED).glob("appmanifest_*.acf")):
appid = manifest.stem.removeprefix("appmanifest_")
live = steamapps / manifest.name
if os.path.lexists(live):
manifest.unlink(missing_ok=True)
elif (steamapps / "common" / f"DroidDeck-{appid}").is_dir():
os.replace(manifest, live)
def park(steamapps):
for target in sorted((steamapps / "common").glob("DroidDeck-*")):
appid = target.name.removeprefix("DroidDeck-")
manifest = steamapps / f"appmanifest_{appid}.acf"
if appid.isdigit() and target.is_symlink() and not target.exists() and manifest.is_file() and ours(manifest, appid):
(steamapps / PARKED).mkdir(exist_ok=True)
os.replace(manifest, steamapps / PARKED / manifest.name)
print(f"droiddeck-steam-games: {appid} is not here this session; its install is set aside until it is")
def seed_install(steam_root, game, appid):
steamapps = Path(steam_root) / "steamapps"
common = steamapps / "common"
@@ -305,7 +342,7 @@ def seed_install(steam_root, game, appid):
relink.unlink(missing_ok=True)
relink.symlink_to(source, target_is_directory=True)
os.replace(relink, target)
for root in [Path(steam_root), Path(LIBRARY)]:
for root in libraries(steam_root):
existing = root / "steamapps" / manifest.name
if existing.is_file():
if (existing == manifest and source.is_dir() and target.is_symlink()
@@ -343,6 +380,11 @@ def route(steam_root, acct, games):
if not all(isinstance(value, dict) for value in (owned, candidates, sources)):
owned, candidates, sources = {}, {}, {}
shortcuts, routes = [], {}
steamapps = Path(steam_root) / "steamapps"
try:
unpark(steamapps)
except OSError as error:
print(f"droiddeck-steam-games: set-aside installs not restored ({error})")
for game in games:
folder = game.get("folder", "")
appid = candidates.get(folder, candidates.get(legacy_path(folder)))
@@ -359,6 +401,10 @@ def route(steam_root, acct, games):
except OSError as error:
print(f"droiddeck-steam-games: import unavailable ({error})")
shortcuts.append(game)
try:
park(steamapps)
except OSError as error:
print(f"droiddeck-steam-games: missing installs not set aside ({error})")
save_json(Path(acct) / "config/.droiddeck-routes.json", routes)
return shortcuts, routes
@@ -5,6 +5,7 @@
# droiddeck-steam-library <steam root>
#
# Ported from WinNative's gamescope runtime (GPL-3.0).
import json
import os
import re
import sys
@@ -168,6 +169,29 @@ def relabel(steam_root, target, label):
print("droiddeck-steam-library: labelled %s in its own marker too" % target)
GAMES_DIR = "/root/Games/"
def wanted():
listing = os.environ.get("BL_STEAM_LIBRARIES")
if not listing:
return None
try:
with open(listing, encoding="utf-8") as f:
entries = json.load(f)
return [(str(e["path"]), str(e["label"]).replace('"', " ").replace("\\", " ")) for e in entries]
except (OSError, ValueError, KeyError, TypeError):
return None
def registered(steam_root):
try:
with open(os.path.join(steam_root, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f:
return re.findall(r'"path"\s+"([^"]*)"', f.read())
except OSError:
return []
def main():
steam_root = sys.argv[1]
for library in RETIRED:
@@ -175,12 +199,19 @@ def main():
for old, new in RENAMED:
rename(steam_root, old, new)
relabel(steam_root, steam_root, MAIN_LABEL)
for library, label in LIBRARIES:
libraries = wanted()
managed = libraries is not None
for library, label in libraries if managed else LIBRARIES:
if os.path.isdir(os.path.join(library, "steamapps")):
register(steam_root, library, label)
relabel(steam_root, library, label)
else:
forget(steam_root, library)
if managed:
keep = {library for library, _ in libraries}
for library in registered(steam_root):
if library not in keep and (library == LIBRARIES[0][0] or library.startswith(GAMES_DIR)):
forget(steam_root, library)
if __name__ == "__main__":
@@ -120,6 +120,114 @@ def shortcut(game, launch_options=""):
}
RECORD = ".droiddeck-shortcuts.json"
AWAY_LIMIT = 256
FIELDS = ("AppName", "Exe", "StartDir", "icon")
def unquoted(value):
return value.strip().strip('"') if isinstance(value, str) else ""
def read_record(path):
try:
with open(path, encoding="utf-8") as f:
record = json.load(f)
except (OSError, ValueError):
record = {}
if not isinstance(record, dict):
record = {}
games, away = record.get("games"), record.get("away")
return {
"games": {k: v for k, v in games.items() if isinstance(v, dict)} if isinstance(games, dict) else {},
"away": {k: v for k, v in away.items() if isinstance(v, dict) and isinstance(v.get("entry"), dict)} if isinstance(away, dict) else {},
}
def merge(fresh, current, state, seen):
state = state if isinstance(state, dict) else {}
rev = state.get("rev") if isinstance(state.get("rev"), int) else 0
if current is None:
return fresh, rev
base = state.get("app") if isinstance(state.get("app"), dict) else {field: current.get(field) for field in FIELDS}
before = state.get("steam") if isinstance(state.get("steam"), dict) else {"Exe": current.get("Exe")}
if unquoted(current.get("Exe")) != unquoted(before.get("Exe")):
rev += 1
entry = dict(current)
entry["appid"] = fresh["appid"]
exe = unquoted(fresh["Exe"])
if exe != unquoted(current.get("Exe")) and (exe != unquoted(base.get("Exe")) or seen == rev):
entry["Exe"], entry["StartDir"] = fresh["Exe"], fresh["StartDir"]
for field in ("AppName", "icon"):
if current.get(field) == base.get(field):
entry[field] = fresh[field]
tags = dict(current["tags"]) if isinstance(current.get("tags"), dict) else {}
if TAG not in tags.values():
slot = 0
while str(slot) in tags:
slot += 1
tags[str(slot)] = TAG
entry["tags"] = tags
for key, value in fresh.items():
entry.setdefault(key, value)
return entry, rev
def reconcile(existing, wanted, seen, record):
games, away = record["games"], dict(record["away"])
kept, ours, placed = [], [], {}
for value in existing.values():
if not isinstance(value, dict):
continue
tags = value.get("tags")
if not isinstance(tags, dict) or TAG not in tags.values():
kept.append(value)
continue
appid = value.get("appid")
if not isinstance(appid, int) or appid in placed:
continue
key = str(appid & 0xFFFFFFFF)
fresh = wanted.get(appid)
if fresh is None:
away.pop(key, None)
away[key] = {"entry": value, "state": games.get(key)}
continue
entry, rev = merge(fresh, value, games.get(key), seen.get(appid, 0))
placed[appid] = (len(ours), rev)
ours.append(entry)
for appid, fresh in wanted.items():
if appid in placed:
continue
key = str(appid & 0xFFFFFFFF)
stashed = away.get(key)
if stashed:
entry, rev = merge(fresh, stashed["entry"], stashed.get("state"), seen.get(appid, 0))
else:
entry, rev = merge(fresh, None, games.get(key), seen.get(appid, 0))
placed[appid] = (len(ours), rev)
ours.append(entry)
for appid in placed:
away.pop(str(appid & 0xFFFFFFFF), None)
while len(away) > AWAY_LIMIT:
away.pop(next(iter(away)))
state = {}
for appid, (index, rev) in placed.items():
entry = ours[index]
state[str(appid & 0xFFFFFFFF)] = {
"app": {field: wanted[appid][field] for field in FIELDS},
"steam": {"Exe": entry.get("Exe", ""), "StartDir": entry.get("StartDir", "")},
"rev": rev,
}
return kept, ours, {"games": state, "away": away}
def save_record(path, record):
tmp = path + ".droiddeck"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(record, f)
os.replace(tmp, path)
ART_MANIFEST = ".droiddeck-art.json"
# listing key -> the client's grid file suffix: <appid>p = portrait capsule, <appid> = header,
# <appid>_hero, <appid>_logo.
@@ -208,27 +316,21 @@ def main():
routing = importlib.util.module_from_spec(spec)
loader.exec_module(routing)
games, routes = routing.route(steam_root, acct, games)
# Steam saves Big Picture's per-shortcut launch options in shortcuts.vdf. Keep them when
# refreshing our entries, keyed by the appid that stays stable across session starts.
managed = {
v.get("appid"): v for v in existing.values()
if isinstance(v, dict) and TAG in (v.get("tags") or {}).values()
}
kept = [v for v in existing.values() if isinstance(v, dict) and TAG not in (v.get("tags") or {}).values()]
ours = []
wanted, seen = {}, {}
for game in games:
if not game.get("exe") or not game.get("name"):
continue
entry = shortcut(game)
previous = managed.get(entry["appid"])
if previous and isinstance(previous.get("LaunchOptions"), str):
entry["LaunchOptions"] = previous["LaunchOptions"]
ours.append(entry)
if game.get("exe") and game.get("name"):
entry = shortcut(game)
if entry["appid"] not in wanted:
wanted[entry["appid"]] = entry
seen[entry["appid"]] = game["seen"] if isinstance(game.get("seen"), int) else 0
record_path = os.path.join(config, RECORD)
kept, ours, record = reconcile(existing, wanted, seen, read_record(record_path))
entries = {str(i): v for i, v in enumerate(kept + ours)}
tmp = path + ".droiddeck"
with open(tmp, "wb") as f:
f.write(write({"shortcuts": entries}))
os.replace(tmp, path)
save_record(record_path, record)
print("droiddeck-steam-shortcuts: %d added game(s) written, %d of the client's own kept" % (len(ours), len(kept)))
install_art(acct, [g for g in games if g.get("exe") and g.get("name")])
# Each added game runs under the ARM64 Proton: the compat registrar's own mapping writer.
@@ -1,5 +1,10 @@
#!/usr/bin/python3
"""Initialize Steam's own Gamescope UI scale once; subsequent choices belong to Steam."""
"""Initialize Steam's own Gamescope display settings once; subsequent choices belong to Steam.
The UI scale, and native resolution: the client treats our gamescope as an external display and,
unless "Enable Native Resolution on External Displays" is on, draws itself at 1920x1080 on any
larger output - a 2340x1080 session came up as 16:9 with bars. The session's size is already the
user's Resolution choice, so the client should use all of it."""
import math
import os
from pathlib import Path
@@ -9,6 +14,7 @@ import tempfile
DISPLAY = "External: gamescope|||Windowed"
NATIVE_RESOLUTION = "EnableSteamNativeExternalResolution"
TOKEN = re.compile(r'\s+|//[^\n]*|"(?:\\.|[^"\\])*"|[{}]')
@@ -102,6 +108,25 @@ def seed_text(text, scale):
return text[:at] + insertion + text[at:], True
def seed_native_resolution(text):
root = parse(text).get('InstallConfigStore')
if not isinstance(root, Block):
raise ValueError("missing InstallConfigStore")
gamescope = root.get('Gamescope')
if gamescope is not None and not isinstance(gamescope, Block):
raise ValueError("invalid Gamescope settings")
if gamescope is not None and gamescope.get(NATIVE_RESOLUTION) is not None:
return text, False
entry = f'"{NATIVE_RESOLUTION}" "1"'
if gamescope is not None:
at, indent = gamescope.end, 2
else:
entry = '"Gamescope"\n{\n' + entry + '\n}'
at, indent = root.end, 1
insertion = '\n' + '\n'.join('\t' * indent + line for line in entry.splitlines()) + '\n'
return text[:at] + insertion + text[at:], True
def atomic_write(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
@@ -128,9 +153,30 @@ def initialize(steam_root, width, height):
return changed
def initialize_native_resolution(steam_root):
config = Path(steam_root) / 'config/config.vdf'
marker = config.with_name('droiddeck-native-resolution-initialized')
if marker.exists():
return False
text = config.read_text() if config.exists() else '"InstallConfigStore"\n{\n}\n'
updated, changed = seed_native_resolution(text)
if changed:
atomic_write(config, updated)
print('== steam: native resolution on the session display')
atomic_write(marker, '1\n')
return changed
if __name__ == '__main__':
status = 0
try:
initialize(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))
except (OSError, ValueError, RecursionError) as error:
print('== steam: left UI scale unchanged: ' + str(error), file=sys.stderr)
sys.exit(1)
status = 1
try:
initialize_native_resolution(sys.argv[1])
except (OSError, ValueError, RecursionError) as error:
print('== steam: left native resolution unchanged: ' + str(error), file=sys.stderr)
status = 1
sys.exit(status)
@@ -277,9 +277,10 @@ done
# The depot this tool is named after, newest across the libraries that hold it; the others in the
# list stand in only when it is missing.
depot=
libraries=$(printf '%%s\\n' "$steam_root" /mnt/droiddeck-sd; sed -n 's/^[[:space:]]*"path"[[:space:]]*"\\(.*\\)"[[:space:]]*$/\\1/p' "$steam_root/steamapps/libraryfolders.vdf" 2>/dev/null)
for name in %s; do
best=0
for library in "$steam_root" /mnt/droiddeck-sd; do
while IFS= read -r library; do
candidate="$library/steamapps/common/$name"
[ -d "$candidate/files/bin-arm64" ] && [ -x "$candidate/proton" ] || continue
build=0
@@ -289,7 +290,7 @@ for name in %s; do
depot=$candidate
best=$build
fi
done
done <<< "$libraries"
[ -n "$depot" ] && break
done
if [ -n "$depot" ]; then
@@ -615,10 +616,21 @@ NOT_GAMES = frozenset((
))
def libraries(steam_root):
found = [steam_root]
try:
with open(os.path.join(steam_root, "steamapps", "libraryfolders.vdf"), encoding="utf-8", errors="replace") as f:
found += [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', f.read())]
except OSError:
pass
found.append(LIBRARY + "-sd")
return list(dict.fromkeys(found))
def installed_apps(steam_root):
"""App ids of the titles the client has installed, tools and runtimes excluded."""
apps = set()
for library in (steam_root, LIBRARY + "-sd"):
for library in libraries(steam_root):
steamapps = os.path.join(library, "steamapps")
try:
names = os.listdir(steamapps)
@@ -778,7 +790,7 @@ def fex_missing(steam_root):
missing = []
for app in FEX_DEPENDENCIES:
if not any(os.path.isfile(os.path.join(library, "steamapps", "appmanifest_%s.acf" % app))
for library in (steam_root, LIBRARY + "-sd")):
for library in libraries(steam_root)):
missing.append(app)
return missing
@@ -1065,7 +1077,7 @@ def depot_build(depot):
def find_source(steam_root, sources=SOURCES):
"""The first of `sources` the client has downloaded, newest across the libraries holding it."""
for name in sources:
found = [os.path.join(root, "steamapps", "common", name) for root in (steam_root, "/mnt/droiddeck-sd")
found = [os.path.join(root, "steamapps", "common", name) for root in libraries(steam_root)
if os.path.isdir(os.path.join(root, "steamapps", "common", name, "files", "bin-arm64"))]
if found:
return max(found, key=depot_build)
+5
View File
@@ -1237,9 +1237,14 @@ static void ns_received(struct msghdr *msg) {
}
}
int bl_udevmon_recvmsg(int fd, struct msghdr *msg, int flags, ssize_t (*real_recvmsg)(int, struct msghdr *, int),
ssize_t *result) __attribute__((visibility("hidden")));
ssize_t recvmsg(int fd, struct msghdr *msg, int flags) {
static ssize_t (*real)(int, struct msghdr *, int);
if (!real) real = (ssize_t (*)(int, struct msghdr *, int))dlsym(RTLD_NEXT, "recvmsg");
ssize_t answered;
if (msg && bl_udevmon_recvmsg(fd, msg, flags, real, &answered)) return answered;
ssize_t r = real(fd, msg, flags);
if (r >= 0 && msg && msg->msg_control && msg->msg_controllen && ns_on()) {
int saved = errno;
+63 -12
View File
@@ -1,8 +1,6 @@
/*
* Opt-in observations for file allocation and writes to the selected Steam library.
*
* Steam's allocation failure and any later fallback are left entirely to the caller. These
* wrappers only forward the same arguments, record the result and restore errno after logging.
* The session enables them only when storage diagnostics are turned on. Counters are local to
* each process, allocation details are capped, writes are sampled, and storage.log has a global
* size limit because every Steam child shares it.
@@ -154,19 +152,56 @@ static void allocation_line(const char *api, int result, int error, int flags,
if (n > 0 && (size_t)n < sizeof(line)) append_line(line, (size_t)n);
}
static int steam_client(void) {
static int cached = -1;
if (cached < 0)
cached = strcmp(program_invocation_short_name, "steam") == 0 &&
strstr(program_invocation_name, "steamrtarm64") != NULL;
return cached;
}
static int staging_file(int fd) {
char link[32], path[PATH_MAX];
snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
long n = syscall(SYS_readlinkat, (long)AT_FDCWD, (long)link, (long)path, (long)sizeof(path) - 1);
if (n <= 0) return 0;
path[n] = '\0';
return strstr(path, "/steamapps/downloading/") != NULL;
}
static long long unreserved[16];
static unsigned int unreserved_count;
static int cannot_reserve(long long device) {
unsigned int n = __atomic_load_n(&unreserved_count, __ATOMIC_ACQUIRE);
for (unsigned int i = 0; i < n && i < 16; i++)
if (__atomic_load_n(&unreserved[i], __ATOMIC_RELAXED) == device) return 1;
return 0;
}
static void note_cannot_reserve(int fd) {
struct stat st;
if (next_fstat == NULL) next_fstat = dlsym(RTLD_NEXT, "fstat");
if (next_fstat == NULL || next_fstat(fd, &st) != 0 || cannot_reserve((long long)st.st_dev)) return;
unsigned int slot = __atomic_load_n(&unreserved_count, __ATOMIC_RELAXED);
while (slot < 16 && !__atomic_compare_exchange_n(&unreserved_count, &slot, slot + 1, 0, __ATOMIC_ACQ_REL,
__ATOMIC_RELAXED)) {
}
if (slot < 16) __atomic_store_n(&unreserved[slot], (long long)st.st_dev, __ATOMIC_RELEASE);
}
int fallocate(int fd, int mode, off_t offset, off_t length) {
int entry_errno = errno;
if (next_fallocate == NULL) next_fallocate = dlsym(RTLD_NEXT, "fallocate");
if (next_fallocate == NULL) { errno = ENOSYS; return -1; }
int selected = on_selected_device(fd, NULL);
errno = entry_errno;
if (!selected) return next_fallocate(fd, mode, offset, length);
uint64_t start = now_ns();
uint64_t start = selected ? now_ns() : 0;
errno = entry_errno;
int result = next_fallocate(fd, mode, offset, length);
int call_errno = errno;
uint64_t elapsed = now_ns() - start;
allocation_line("fallocate", result, call_errno, mode, offset, length, elapsed);
if (result != 0 && (call_errno == EOPNOTSUPP || call_errno == ENOSYS) && steam_client()) note_cannot_reserve(fd);
if (selected) allocation_line("fallocate", result, call_errno, mode, offset, length, now_ns() - start);
errno = call_errno;
return result;
}
@@ -222,12 +257,28 @@ int ftruncate(int fd, off_t length) {
if (next_ftruncate == NULL) next_ftruncate = dlsym(RTLD_NEXT, "ftruncate");
if (next_ftruncate == NULL) { errno = ENOSYS; return -1; }
int selected = on_selected_device(fd, NULL);
errno = entry_errno;
if (!selected) return next_ftruncate(fd, length);
uint64_t start = now_ns();
errno = entry_errno;
int result = next_ftruncate(fd, length);
int call_errno = errno;
uint64_t start = selected ? now_ns() : 0;
struct stat st;
int known = 0;
if (steam_client()) {
if (next_fstat == NULL) next_fstat = dlsym(RTLD_NEXT, "fstat");
known = next_fstat != NULL && next_fstat(fd, &st) == 0 && S_ISREG(st.st_mode) && staging_file(fd);
}
int result = 0, call_errno = entry_errno;
if (!known || length <= st.st_size || !cannot_reserve((long long)st.st_dev)) {
errno = entry_errno;
result = next_ftruncate(fd, length);
call_errno = errno;
if (result != 0 && known && length >= st.st_size && (call_errno == EPERM || call_errno == EOPNOTSUPP)) {
if (length > st.st_size) note_cannot_reserve(fd);
result = 0;
call_errno = entry_errno;
}
}
if (!selected) {
errno = call_errno;
return result;
}
uint64_t elapsed = now_ns() - start;
add(&stats.ftruncate_calls, 1);
if (result != 0) add(&stats.ftruncate_failures, 1);
+184
View File
@@ -32,6 +32,10 @@
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>
#include <dirent.h>
#include <endian.h>
#include <stdio.h>
#include <time.h>
struct stand_in {
int fd; /* the descriptor handed out, -1 when the slot is free */
@@ -95,8 +99,11 @@ static int is_wine_process(void) {
return known == 1;
}
static void start_injector(void);
static int make_stand_in(int type) {
int sv[2];
start_injector();
int saved = errno;
int flags = SOCK_DGRAM | (type & (SOCK_CLOEXEC | SOCK_NONBLOCK));
if (socketpair(AF_UNIX, flags, 0, sv) != 0) {
@@ -189,3 +196,180 @@ int setsockopt(int fd, int level, int optname, const void *optval, socklen_t opt
}
return real_setsockopt(fd, level, optname, optval, optlen);
}
/* EXPERIMENT (docs/development/steam-touch-controller.md): hotplug through the stand-in.
*
* A file dropped in INJECT_DIR is a udev event: KEY=VALUE lines (ACTION, DEVPATH, SUBSYSTEM,
* DEVNAME, MAJOR, MINOR...). It is sent to every stand-in as udevd would send it to the "udev"
* group, and recvmsg below dresses it as udevd's: the udev multicast group as the sender address
* and root credentials, without which libudev drops a message. SDL's HID discovery counts an
* add or remove and enumerates again, so a hidraw node can come and go while the client runs. */
#define INJECT_DIR "/tmp/udev-inject"
struct monitor_netlink_header {
char prefix[8];
unsigned magic;
unsigned header_size;
unsigned properties_off;
unsigned properties_len;
unsigned filter_subsystem_hash;
unsigned filter_devtype_hash;
unsigned filter_tag_bloom_hi;
unsigned filter_tag_bloom_lo;
};
static unsigned murmur_hash2(const char *key, size_t len) {
/* systemd's string_hash32 (MurmurHash2, seed 0) */
const unsigned m = 0x5bd1e995;
const int r = 24;
unsigned h = (unsigned)len;
const unsigned char *data = (const unsigned char *)key;
while (len >= 4) {
unsigned k;
memcpy(&k, data, 4);
k *= m; k ^= k >> r; k *= m;
h *= m; h ^= k;
data += 4; len -= 4;
}
switch (len) {
case 3: h ^= data[2] << 16; /* fallthrough */
case 2: h ^= data[1] << 8; /* fallthrough */
case 1: h ^= data[0]; h *= m;
}
h ^= h >> 13; h *= m; h ^= h >> 15;
return h;
}
static void inject_file(const char *path, unsigned long long seqnum) {
char text[2048];
FILE *f = fopen(path, "re");
if (f == NULL) return;
size_t n = fread(text, 1, sizeof(text) - 1, f);
fclose(f);
text[n] = '\0';
char props[2300];
size_t used = 0;
char subsystem[64] = "";
char *save = NULL;
for (char *line = strtok_r(text, "\n", &save); line != NULL; line = strtok_r(NULL, "\n", &save)) {
size_t len = strlen(line);
if (len == 0 || used + len + 1 > sizeof(props) - 64) continue;
if (strncmp(line, "SUBSYSTEM=", 10) == 0) snprintf(subsystem, sizeof(subsystem), "%s", line + 10);
memcpy(props + used, line, len + 1);
used += len + 1;
}
used += (size_t)snprintf(props + used, sizeof(props) - used, "SEQNUM=%llu", seqnum) + 1;
struct monitor_netlink_header header;
memset(&header, 0, sizeof(header));
memcpy(header.prefix, "libudev", 8);
header.magic = htobe32(0xfeedcafe);
header.header_size = sizeof(header);
header.properties_off = sizeof(header);
header.properties_len = (unsigned)used;
header.filter_subsystem_hash = htobe32(murmur_hash2(subsystem, strlen(subsystem)));
char message[sizeof(header) + sizeof(props)];
memcpy(message, &header, sizeof(header));
memcpy(message + sizeof(header), props, used);
int sent = 0;
pthread_mutex_lock(&lock);
for (size_t i = 0; i < sizeof(stand_ins) / sizeof(stand_ins[0]); i++) {
if (stand_ins[i].fd == -1) continue;
if (send(stand_ins[i].peer, message, sizeof(header) + used, MSG_DONTWAIT | MSG_NOSIGNAL) > 0) sent++;
}
pthread_mutex_unlock(&lock);
FILE *done = fopen(INJECT_DIR "/.log", "ae");
if (done) {
char exe[PATH_MAX] = "";
ssize_t len = readlink("/proc/self/exe", exe, sizeof(exe) - 1);
if (len > 0) exe[len] = '\0';
fprintf(done, "%s: pid %d (%s) sent to %d stand-in(s)\n", path, (int)getpid(), exe, sent);
fclose(done);
}
}
static void *injector(void *arg) {
(void)arg;
unsigned long long seqnum = 900000;
/* Every process with a stand-in sends each event file once (the client is one of several), so
* files are never claimed; whoever sees one older than a few seconds removes it. */
static char seen[32][128];
size_t next_seen = 0;
for (;;) {
DIR *dir = opendir(INJECT_DIR);
if (dir != NULL) {
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
if (entry->d_name[0] == '.') continue;
char path[300];
snprintf(path, sizeof(path), INJECT_DIR "/%s", entry->d_name);
struct stat st;
if (stat(path, &st) != 0) continue;
if (time(NULL) - st.st_mtime > 5) {
unlink(path);
continue;
}
char key[128];
snprintf(key, sizeof(key), "%s:%lu", entry->d_name, (unsigned long)st.st_ino);
int done = 0;
for (size_t i = 0; i < 32; i++)
if (strcmp(seen[i], key) == 0) done = 1;
if (done) continue;
snprintf(seen[next_seen++ % 32], sizeof(seen[0]), "%s", key);
inject_file(path, ++seqnum);
}
closedir(dir);
}
struct timespec interval = {0, 100 * 1000 * 1000};
nanosleep(&interval, NULL);
}
return NULL;
}
static void spawn_injector(void) {
pthread_t thread;
if (pthread_create(&thread, NULL, injector, NULL) == 0) pthread_detach(thread);
}
static void start_injector(void) {
static pthread_once_t once = PTHREAD_ONCE_INIT;
pthread_once(&once, spawn_injector);
}
/* For ntsync.c's recvmsg(), which owns the symbol: 1 when fd was a stand-in and was answered here
* (*result holds what recvmsg returns), 0 when it is not one. */
__attribute__((visibility("hidden"))) int bl_udevmon_recvmsg(int fd, struct msghdr *msg, int flags,
ssize_t (*real_recvmsg)(int, struct msghdr *, int),
ssize_t *result) {
if (!is_stand_in(fd)) return 0;
size_t control_room = msg->msg_controllen;
socklen_t name_room = msg->msg_namelen;
ssize_t n = real_recvmsg(fd, msg, flags);
*result = n;
if (n >= 0) {
FILE *log = fopen(INJECT_DIR "/.recv", "ae");
if (log) {
fprintf(log, "pid %d fd %d got %zd bytes, name room %u, control room %zu\n", (int)getpid(), fd, n,
(unsigned)name_room, control_room);
fclose(log);
}
}
if (n < 0) return 1;
if (msg->msg_name != NULL && name_room >= sizeof(struct sockaddr_nl)) {
struct sockaddr_nl nl;
memset(&nl, 0, sizeof(nl));
nl.nl_family = AF_NETLINK;
nl.nl_groups = 2; /* udev's multicast group */
memcpy(msg->msg_name, &nl, sizeof(nl));
msg->msg_namelen = sizeof(nl);
}
if (msg->msg_control != NULL && control_room >= CMSG_SPACE(sizeof(struct ucred))) {
struct cmsghdr *cmsg = (struct cmsghdr *)msg->msg_control;
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_CREDENTIALS;
cmsg->cmsg_len = CMSG_LEN(sizeof(struct ucred));
struct ucred cred = {.pid = 1, .uid = 0, .gid = 0};
memcpy(CMSG_DATA(cmsg), &cred, sizeof(cred));
msg->msg_controllen = CMSG_SPACE(sizeof(struct ucred));
}
return 1;
}
+40
View File
@@ -1,6 +1,7 @@
import importlib.machinery
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
@@ -231,6 +232,45 @@ class ImportsTest(unittest.TestCase):
self.folder.rmdir()
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
def test_absent_folder_sets_its_install_aside_until_it_returns(self):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
manifest = self.steam / 'steamapps/appmanifest_42.acf'
manifest.write_text(manifest.read_text().replace('}', ' "buildid" "99"\n}'))
state = manifest.read_text()
away = self.root / 'unplugged'
self.folder.rename(away)
with patch('builtins.print'):
self.assertEqual(([], {}), imports.route(self.steam, self.acct, []))
self.assertFalse(manifest.exists())
self.assertEqual(state, (self.steam / 'steamapps' / imports.PARKED / manifest.name).read_text())
away.rename(self.folder)
self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game]))
self.assertEqual(state, manifest.read_text())
self.assertFalse((self.steam / 'steamapps' / imports.PARKED / manifest.name).exists())
def test_steam_installs_are_never_set_aside(self):
manifest = self.steam / 'steamapps/appmanifest_42.acf'
manifest.parent.mkdir(parents=True)
manifest.write_text('"AppState"\n{\n "appid" "42"\n "installdir" "Example"\n}\n')
link = self.steam / 'steamapps/common/DroidDeck-42'
link.parent.mkdir(parents=True)
link.symlink_to(self.root / 'missing', target_is_directory=True)
imports.route(self.steam, self.acct, [])
self.assertTrue(manifest.is_file())
def test_install_in_another_library_is_used_as_is(self):
other = self.root / 'Games/PC'
(other / 'steamapps').mkdir(parents=True)
(other / 'steamapps/appmanifest_42.acf').write_text('Steam installed it here')
vdf = self.steam / 'steamapps/libraryfolders.vdf'
vdf.parent.mkdir(parents=True)
vdf.write_text('"libraryfolders"\n{\n\t"1"\n\t{\n\t\t"path"\t\t"%s"\n\t}\n}\n' % other)
self.snapshot()
self.assertEqual(([], {str(self.game['appid']): 42}), imports.route(self.steam, self.acct, [self.game]))
self.assertFalse((self.steam / 'steamapps/appmanifest_42.acf').exists())
self.assertFalse(os.path.lexists(self.steam / 'steamapps/common/DroidDeck-42'))
def test_replaced_game_does_not_inherit_cached_ownership(self):
self.snapshot()
self.exe.write_bytes(b'a different game')
+152
View File
@@ -0,0 +1,152 @@
import importlib.machinery
import importlib.util
import json
import os
import re
import runpy
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
def load(name):
loader = importlib.machinery.SourceFileLoader(name.replace("-", "_"), str(BIN / name))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
library = load("droiddeck-steam-library")
desktop = load("droiddeck-desktop-games")
COMPAT = runpy.run_path(str(BIN / "steam-compatibility"))
def entry(index, path, label=""):
return '\t"%d"\n\t{\n\t\t"path"\t\t"%s"\n\t\t"label"\t\t"%s"\n\t}\n' % (index, path, label)
def manifest(steamapps, appid, name="Game", installdir="Game", flags="4"):
steamapps.mkdir(parents=True, exist_ok=True)
(steamapps / ("appmanifest_%s.acf" % appid)).write_text(
'"AppState"\n{\n\t"appid"\t\t"%s"\n\t"name"\t\t"%s"\n\t"StateFlags"\t\t"%s"\n\t"installdir"\t\t"%s"\n}\n'
% (appid, name, flags, installdir))
class LibraryListTest(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.root = Path(temp.name)
self.steam = self.root / "Steam"
self.vdf = self.steam / "steamapps/libraryfolders.vdf"
self.vdf.parent.mkdir(parents=True)
self.games = self.root / "Games"
self.card = self.root / "card"
self.pc = self.games / "PC"
self.old = self.games / "Old"
self.user = self.root / "user-library"
for folder in (self.pc, self.old, self.user, self.card):
(folder / "steamapps").mkdir(parents=True)
manifest(self.pc / "steamapps", 10)
self.vdf.write_text('"libraryfolders"\n{\n' + entry(0, self.steam) + entry(1, self.old, "Old")
+ entry(2, self.user, "Mine") + entry(3, self.card, "SD Card") + "}\n")
self.listing = self.root / "steam-libraries.json"
for target in (patch.object(library, "GAMES_DIR", str(self.games) + "/"),
patch.object(library, "LIBRARIES", ((str(self.card), "SD Card"),)),
patch("builtins.print")):
target.start()
self.addCleanup(target.stop)
def run_main(self, listing=True):
with patch.dict(os.environ), patch("sys.argv", ["droiddeck-steam-library", str(self.steam)]):
os.environ.pop("BL_STEAM_LIBRARIES", None)
if listing:
os.environ["BL_STEAM_LIBRARIES"] = str(self.listing)
library.main()
return self.vdf.read_text()
def paths(self, text):
return [Path(p) for p in re.findall(r'"path"\s+"([^"]*)"', text)]
def test_listed_games_folder_is_registered_and_the_rest_are_forgotten(self):
self.listing.write_text(json.dumps([{"path": str(self.pc), "label": "PC (PNY USB drive)"}]))
text = self.run_main()
self.assertEqual([self.steam, self.user, self.pc], self.paths(text))
self.assertIn('"label"\t\t"PC (PNY USB drive)"', text)
self.assertIn('"10"\t\t"0"', text)
self.assertIn('"label"\t\t"PC (PNY USB drive)"', (self.pc / "libraryfolder.vdf").read_text())
def test_absent_listed_folder_is_forgotten_and_comes_back(self):
self.listing.write_text(json.dumps([{"path": str(self.pc), "label": "PC"}, {"path": str(self.card), "label": "SD Card"}]))
self.run_main()
moved = self.root / "unplugged"
self.pc.rename(moved)
self.assertNotIn(self.pc, self.paths(self.run_main()))
moved.rename(self.pc)
self.assertIn(self.pc, self.paths(self.run_main()))
self.assertIn(self.card, self.paths(self.vdf.read_text()))
def test_without_a_list_only_the_card_is_managed(self):
text = self.run_main(listing=False)
self.assertEqual([self.steam, self.old, self.user, self.card], self.paths(text))
def test_unreadable_list_keeps_games_folder_entries(self):
self.listing.write_text("{not json")
text = self.run_main()
self.assertEqual([self.steam, self.old, self.user, self.card], self.paths(text))
def test_labels_cannot_break_the_vdf(self):
self.listing.write_text(json.dumps([{"path": str(self.pc), "label": 'P"C\\'}]))
text = self.run_main()
self.assertIn('"label"\t\t"P C "', text)
class CompatLibrariesTest(unittest.TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.root = Path(temp.name)
self.steam = self.root / "Steam"
self.pc = self.root / "Games/My PC"
(self.steam / "steamapps").mkdir(parents=True)
(self.steam / "steamapps/libraryfolders.vdf").write_text(
'"libraryfolders"\n{\n' + entry(0, self.steam) + entry(1, self.pc, "PC") + "}\n")
def test_titles_and_tools_in_a_registered_library_are_found(self):
manifest(self.steam / "steamapps", 20)
manifest(self.pc / "steamapps", 30)
manifest(self.pc / "steamapps", 3127680, installdir="FEX")
self.assertEqual(["20", "30"], COMPAT["installed_apps"](str(self.steam)))
self.assertEqual(["1628350"], COMPAT["fex_missing"](str(self.steam)))
def test_newest_proton_across_libraries_wins(self):
for root, build in ((self.steam, "100"), (self.pc, "200")):
depot = root / "steamapps/common/Proton Experimental (ARM64)"
(depot / "files/bin-arm64").mkdir(parents=True)
(depot / "version").write_text(build + " x\n")
self.assertEqual(str(self.pc / "steamapps/common/Proton Experimental (ARM64)"), COMPAT["find_source"](str(self.steam)))
def test_missing_list_still_reads_the_client_library(self):
(self.steam / "steamapps/libraryfolders.vdf").unlink()
self.assertEqual(str(self.steam), COMPAT["libraries"](str(self.steam))[0])
class DesktopGamesTest(unittest.TestCase):
def test_games_in_registered_libraries_get_entries(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
steam, pc = root / "Steam", root / "Games/PC"
manifest(steam / "steamapps", 40, name="Internal")
manifest(pc / "steamapps", 50, name="Outside")
(steam / "steamapps/libraryfolders.vdf").write_text('"libraryfolders"\n{\n' + entry(0, steam) + entry(1, pc) + "}\n")
with patch.object(desktop, "STEAM_ROOT", str(steam)), patch.object(desktop, "APPS_DIR", str(root / "apps")), \
patch.object(desktop, "LIBRARIES", ((str(steam), "Steam Games"), (str(root / "card"), "SD Games"))):
self.assertEqual({40: "Internal", 50: "Outside"}, desktop.write_entries())
if __name__ == "__main__":
unittest.main()
+169
View File
@@ -0,0 +1,169 @@
import importlib.machinery
import importlib.util
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
def load(name):
loader = importlib.machinery.SourceFileLoader(name, str(BIN / name))
spec = importlib.util.spec_from_loader(name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
shortcuts = load('droiddeck-steam-shortcuts')
APPID = 0x87654321
GUEST = '/root/Games/PC/Example'
class ShortcutsTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.steam = self.root / 'Steam'
self.config = self.steam / 'userdata/123/config'
self.config.mkdir(parents=True)
self.listing = self.root / 'added-games.json'
def game(self, exe='Example.exe', seen=0, appid=APPID, name='Example'):
path = GUEST + '/' + exe
return dict(name=name, exe=path, folder=GUEST, dir=path.rsplit('/', 1)[0], appid=appid, seen=seen)
def run_writer(self, *games):
self.listing.write_text(json.dumps(list(games)))
subprocess.run(['python3', str(BIN / 'droiddeck-steam-shortcuts'), str(self.steam), str(self.listing)],
check=True, capture_output=True)
return self.entries()
def entries(self):
data = shortcuts.parse((self.config / 'shortcuts.vdf').read_bytes())['shortcuts']
return {value['appid'] & 0xFFFFFFFF: value for value in data.values()}
def steam_edits(self, appid=APPID, **fields):
path = self.config / 'shortcuts.vdf'
data = shortcuts.parse(path.read_bytes())['shortcuts']
for value in data.values():
if value['appid'] & 0xFFFFFFFF == appid:
value.update(fields)
path.write_bytes(shortcuts.write({'shortcuts': data}))
def record(self):
return json.loads((self.config / shortcuts.RECORD).read_text())
def test_settings_changed_in_steam_survive_its_restart(self):
self.run_writer(self.game())
edits = dict(Exe='"%s/bin/Other.exe"' % GUEST, StartDir='"%s"' % GUEST, LaunchOptions='DXVK_HUD=1 %command%',
AppName='Example GOTY', icon='/root/custom.png', IsHidden=1, AllowOverlay=0, LastPlayTime=1791372037,
sortas='example', tags={'0': shortcuts.TAG, '1': 'Favorites'})
self.steam_edits(**edits)
entry = self.run_writer(self.game())[APPID]
for field, value in edits.items():
self.assertEqual(value, entry[field], field)
entry = self.run_writer(self.game())[APPID]
for field, value in edits.items():
self.assertEqual(value, entry[field], field)
def test_steam_edit_is_recorded_for_the_app(self):
self.run_writer(self.game())
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST)
self.run_writer(self.game())
state = self.record()['games'][str(APPID)]
self.assertEqual('"%s/Example.exe"' % GUEST, state['app']['Exe'])
self.assertEqual('"%s/bin/Other.exe"' % GUEST, state['steam']['Exe'])
self.assertEqual(1, state['rev'])
self.run_writer(self.game())
self.assertEqual(1, self.record()['games'][str(APPID)]['rev'])
def test_the_app_taking_steams_target_keeps_steams_start_in(self):
self.run_writer(self.game())
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST, StartDir='"%s/data"' % GUEST)
self.run_writer(self.game())
entry = self.run_writer(self.game('bin/Other.exe', seen=1))[APPID]
self.assertEqual('"%s/bin/Other.exe"' % GUEST, entry['Exe'])
self.assertEqual('"%s/data"' % GUEST, entry['StartDir'])
def test_an_exe_chosen_in_the_app_replaces_steams_target(self):
self.run_writer(self.game())
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST, LaunchOptions='-windowed')
self.run_writer(self.game())
entry = self.run_writer(self.game('tools/Launcher.exe'))[APPID]
self.assertEqual('"%s/tools/Launcher.exe"' % GUEST, entry['Exe'])
self.assertEqual('"%s/tools"' % GUEST, entry['StartDir'])
self.assertEqual('-windowed', entry['LaunchOptions'])
def test_going_back_to_the_apps_exe_after_taking_steams_wins(self):
self.run_writer(self.game())
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST)
self.run_writer(self.game())
entry = self.run_writer(self.game(seen=1))[APPID]
self.assertEqual('"%s/Example.exe"' % GUEST, entry['Exe'])
self.assertEqual('"%s"' % GUEST, entry['StartDir'])
def test_a_later_steam_edit_is_kept_until_the_app_takes_it(self):
self.run_writer(self.game())
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST)
self.run_writer(self.game())
self.run_writer(self.game(seen=1))
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST)
entry = self.run_writer(self.game(seen=1))[APPID]
self.assertEqual('"%s/bin/Other.exe"' % GUEST, entry['Exe'])
self.assertEqual(2, self.record()['games'][str(APPID)]['rev'])
def test_a_game_that_goes_away_comes_back_with_its_settings(self):
self.run_writer(self.game())
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST, LaunchOptions='-dx11', LastPlayTime=1791372037)
self.assertEqual({}, self.run_writer())
self.assertIn(str(APPID), self.record()['away'])
entry = self.run_writer(self.game())[APPID]
self.assertEqual('"%s/bin/Other.exe"' % GUEST, entry['Exe'])
self.assertEqual('-dx11', entry['LaunchOptions'])
self.assertEqual(1791372037, entry['LastPlayTime'])
self.assertEqual({}, self.record()['away'])
def test_first_run_keeps_steams_own_fields_and_writes_the_apps_paths(self):
old = dict(shortcuts.shortcut(self.game('Old.exe')), LaunchOptions='-novid', LastPlayTime=5, sortas='x')
mine = dict(shortcuts.shortcut(dict(self.game(appid=0x81111111), name='Mine')), tags={'0': 'Personal'})
(self.config / 'shortcuts.vdf').write_bytes(shortcuts.write({'shortcuts': {'0': mine, '1': old}}))
entries = self.run_writer(self.game())
entry = entries[APPID]
self.assertEqual('"%s/Example.exe"' % GUEST, entry['Exe'])
self.assertEqual('-novid', entry['LaunchOptions'])
self.assertEqual(5, entry['LastPlayTime'])
self.assertEqual('x', entry['sortas'])
self.assertEqual(mine, entries[0x81111111])
def test_a_missing_tag_is_put_back_without_losing_steams(self):
self.run_writer(self.game())
self.steam_edits(tags={'1': 'Favorites', '0': shortcuts.TAG})
entry = self.run_writer(self.game())[APPID]
self.assertEqual({'1': 'Favorites', '0': shortcuts.TAG}, entry['tags'])
self.assertEqual({'0': shortcuts.TAG}, shortcuts.merge(shortcuts.shortcut(self.game()), dict(shortcuts.shortcut(self.game()), tags={}), None, 0)[0]['tags'])
def test_a_damaged_record_is_started_over(self):
self.run_writer(self.game())
(self.config / shortcuts.RECORD).write_text('{not json')
self.steam_edits(LaunchOptions='-safe')
entry = self.run_writer(self.game())[APPID]
self.assertEqual('-safe', entry['LaunchOptions'])
self.assertIn(str(APPID), self.record()['games'])
def test_games_set_aside_are_bounded(self):
many = [self.game(appid=0x80000000 + n, name='Game %d' % n) for n in range(shortcuts.AWAY_LIMIT + 4)]
self.run_writer(*many)
self.run_writer()
away = self.record()['away']
self.assertEqual(shortcuts.AWAY_LIMIT, len(away))
self.assertNotIn(str(0x80000000), away)
self.assertIn(str(0x80000000 + shortcuts.AWAY_LIMIT + 3), away)
if __name__ == '__main__':
unittest.main()
+55
View File
@@ -11,6 +11,9 @@ seed_text = MODULE['seed_text']
parse = MODULE['parse']
initial_scale = MODULE['initial_scale']
DISPLAY = MODULE['DISPLAY']
seed_native_resolution = MODULE['seed_native_resolution']
initialize_native_resolution = MODULE['initialize_native_resolution']
NATIVE_RESOLUTION = MODULE['NATIVE_RESOLUTION']
class SteamUiScaleTest(unittest.TestCase):
@@ -96,3 +99,55 @@ class SteamUiScaleTest(unittest.TestCase):
initialize(self.root, 0, 720)
self.assertFalse(self.config.exists())
self.assertFalse(self.marker.exists())
class SteamNativeResolutionTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.config = self.root / 'config/config.vdf'
self.config.parent.mkdir()
self.marker = self.config.with_name('droiddeck-native-resolution-initialized')
def native(self, text):
return parse(text).get('InstallConfigStore').get('Gamescope').get(NATIVE_RESOLUTION)
def test_fresh_client_renders_at_the_session_size(self):
self.assertTrue(initialize_native_resolution(self.root))
self.assertEqual('1', self.native(self.config.read_text()))
self.assertTrue(self.marker.exists())
def test_existing_client_gets_it_once_beside_its_settings(self):
text = '"InstallConfigStore"\n{\n\t"Gamescope"\n\t{\n\t\t"HDREnabled"\t\t"1"\n\t}\n}\n'
self.config.write_text(text)
self.assertTrue(initialize_native_resolution(self.root))
updated = self.config.read_text()
self.assertEqual('1', self.native(updated))
self.assertEqual('1', parse(updated).get('InstallConfigStore').get('Gamescope').get('HDREnabled'))
def test_users_choice_is_kept(self):
text = f'"InstallConfigStore" {{ "Gamescope" {{ "{NATIVE_RESOLUTION}" "0" }} }}'
self.assertEqual((text, False), seed_native_resolution(text))
def test_turning_it_off_after_initialization_survives_restart(self):
initialize_native_resolution(self.root)
text = self.config.read_text().replace('"1"', '"0"')
self.config.write_text(text)
self.assertFalse(initialize_native_resolution(self.root))
self.assertEqual(text, self.config.read_text())
def test_ui_scale_and_native_resolution_seed_the_same_config(self):
initialize(self.root, 2340, 1080)
initialize_native_resolution(self.root)
text = self.config.read_text()
self.assertEqual('1', self.native(text))
self.assertIsNotNone(parse(text).get('InstallConfigStore').get('UI').get('display').get(DISPLAY))
def test_invalid_config_is_never_rewritten_or_marked_initialized(self):
for text in ('"InstallConfigStore" {', '"InstallConfigStore" { "Gamescope" "bad" }'):
self.config.write_text(text)
with self.assertRaises(ValueError):
initialize_native_resolution(self.root)
self.assertEqual(text, self.config.read_text())
self.assertFalse(self.marker.exists())
+149
View File
@@ -0,0 +1,149 @@
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload"
FAKE = r"""
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>
static void path_of(int fd, char *out, size_t size) {
char link[32];
snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
long n = syscall(SYS_readlinkat, AT_FDCWD, link, out, size - 1);
out[n > 0 ? n : 0] = '\0';
}
int fallocate(int fd, int mode, off_t offset, off_t length) {
(void)fd; (void)mode; (void)offset; (void)length;
errno = EOPNOTSUPP;
return -1;
}
int ftruncate(int fd, off_t length) {
char path[4096];
path_of(fd, path, sizeof(path));
FILE *log = fopen(getenv("FAKE_LOG"), "a");
if (log) { fprintf(log, "%s %lld\n", strrchr(path, '/') + 1, (long long)length); fclose(log); }
if (strstr(path, "ntfs")) { errno = EPERM; return -1; }
return (int)syscall(SYS_ftruncate, fd, length);
}
"""
PROGRAM = r"""
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
int main(int argc, char **argv) {
for (int i = 1; i + 2 < argc; i += 3) {
int fd = open(argv[i + 1], O_RDWR | O_CREAT, 0644);
if (fd < 0) return 2;
long long length = atoll(argv[i + 2]);
int result;
errno = 0;
if (strcmp(argv[i], "fallocate") == 0) result = fallocate(fd, 0, 0, length);
else result = ftruncate(fd, length);
int error = result == 0 ? 0 : errno;
struct stat st;
fstat(fd, &st);
printf("%s %d %s %lld\n", argv[i], result, error ? strerrorname_np(error) : "-", (long long)st.st_size);
close(fd);
}
return 0;
}
"""
@unittest.skipUnless(shutil.which("cc"), "needs a C compiler")
class StoragePreloadTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.dir = Path(tempfile.mkdtemp())
build = cls.dir / "build"
build.mkdir()
(build / "fake.c").write_text(FAKE)
(build / "program.c").write_text(PROGRAM)
cls.storage = build / "storage.so"
cls.fake = build / "fake.so"
subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-o", str(cls.storage), str(PRELOAD / "storage.c"), "-ldl"], check=True)
subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-o", str(cls.fake), str(build / "fake.c")], check=True)
cls.steam = cls.dir / "steamrtarm64/steam"
cls.steam.parent.mkdir()
subprocess.run(["cc", "-O2", "-o", str(cls.steam), str(build / "program.c")], check=True)
cls.other = build / "steamwebhelper"
shutil.copy(cls.steam, cls.other)
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.dir, True)
def setUp(self):
self.lib = Path(tempfile.mkdtemp(dir=self.dir))
self.staging = self.lib / "steamapps/downloading/252410"
self.staging.mkdir(parents=True)
self.log = self.lib / "ftruncate.log"
def run_program(self, *steps, program=None):
env = dict(os.environ, LD_PRELOAD="%s:%s" % (self.storage, self.fake), FAKE_LOG=str(self.log))
env.pop("BL_STORAGE_DIAGNOSTICS", None)
args = [str(program or self.steam)]
for call, path, length in steps:
args += [call, str(path), str(length)]
result = subprocess.run(args, env=env, capture_output=True, text=True, check=True)
return result.stdout.splitlines()
def reached(self):
return self.log.read_text().splitlines() if self.log.exists() else []
def test_reservation_after_failed_fallocate_is_not_zero_filled(self):
data = self.staging / "data.pak"
out = self.run_program(("fallocate", data, 1 << 20), ("ftruncate", data, 1 << 20), ("ftruncate", data, 0))
self.assertEqual(["fallocate -1 EOPNOTSUPP 0", "ftruncate 0 - 0", "ftruncate 0 - 0"], out)
self.assertEqual(["data.pak 0"], self.reached())
def test_reservation_is_real_until_the_device_has_refused_one(self):
data = self.staging / "data.pak"
self.assertEqual(["ftruncate 0 - 4096"], self.run_program(("ftruncate", data, 4096)))
self.assertEqual(["data.pak 4096"], self.reached())
def test_refused_extension_is_reported_as_done_and_remembered(self):
first, second = self.staging / "ntfs-one", self.staging / "two"
out = self.run_program(("ftruncate", first, 8192), ("ftruncate", second, 8192))
self.assertEqual(["ftruncate 0 - 0", "ftruncate 0 - 0"], out)
self.assertEqual(["ntfs-one 8192"], self.reached())
def test_refused_shrink_keeps_its_error(self):
data = self.staging / "ntfs-data"
data.write_bytes(b"x" * 100)
self.assertEqual(["ftruncate -1 EPERM 100"], self.run_program(("ftruncate", data, 10)))
def test_files_outside_staging_are_untouched(self):
manifest = self.lib / "steamapps/ntfs-appmanifest.acf"
out = self.run_program(("fallocate", self.staging / "a", 4096), ("ftruncate", manifest, 4096))
self.assertEqual(["fallocate -1 EOPNOTSUPP 0", "ftruncate -1 EPERM 0"], out)
self.assertEqual(["ntfs-appmanifest.acf 4096"], self.reached())
def test_other_programs_are_untouched(self):
data = self.staging / "ntfs-data"
out = self.run_program(("fallocate", data, 4096), ("ftruncate", data, 4096), program=self.other)
self.assertEqual(["fallocate -1 EOPNOTSUPP 0", "ftruncate -1 EPERM 0"], out)
if __name__ == "__main__":
unittest.main()