From 69c88aa344575c20a3b920456a2e8d33bb8f61ba Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 18 Oct 2026 08:59:54 -0400 Subject: [PATCH] feat: fetch Proton, FEX and the runtimes automatically, and make Linux games run under FEX (#1045) * fix: x86 Linux games that also ship for Windows run under Proton again Since #749 the live compatibility helper treated every installed game with both Windows and x86 Linux builds as a user's native choice, because the client reports an empty tool for unmapped games. Those games were left on the Steam Linux Runtime, which cannot start them here (bwrap fails, or FEX looks for a RootFS that is not shipped), so Valheim, Moonlighter, Half-Life 2, TF2 and others exited within seconds. - An unmapped game the helper never managed now gets the default Proton. Clearing a mapping the helper set is still kept as a native choice. - Native choices are stored under "native_choices". The old "native" list also held games nobody picked, so it is dropped. * feat: Linux builds run under FEX by default, with the runtimes fetched automatically - Steam fetches Proton, FEX, Steam Linux Runtime 3.0 and 4.0 on the first start and keeps them updated instead of leaving them pending. - Games that ship a Linux build go to FEX once it can run them; Windows-only games and installed Windows builds keep the Proton default. - FEX runs games on a combined x86 runtime with the newer glibc and libstdc++ the GL and Vulkan thunks need. - Vulkan display extensions the driver cannot handle are hidden in front of the thunk, without an audit module. - Mono's incremental garbage collector is turned off under FEX, which stops Unity games crashing. - x86 Steamworks finds the x86 steamclient. - 32-bit games read the virtual controller. * fix: controllers work in Linux Unity games such as Valheim Joystick ioctls on an event node now fail with EINVAL like the kernel's evdev, so the SDL2 that Unity embeds reads the pad's real ID instead of ignoring it as 0000:0000. * test: skip the switch script test without node, and the ring tests without gcc * fix: games with a Linux build keep the Proton default The Windows build often runs better, so titles that ship both builds stay on the default Proton again. Users can still pick FEX for one, and that choice is kept. Linux-only titles still go to FEX. * fix: unmapped dual-platform games get Proton even when config.vdf cannot be read --- app/src/main/cpp/fakeinput_steam.cpp | 50 +++-- .../launcher/session/SessionFiles.kt | 2 +- tools/linuxfs/build-x86-preloads.sh | 15 +- tools/linuxfs/fex/thunkaudit.c | 40 ---- tools/linuxfs/fex/vulkanpin.c | 88 ++++++++ .../overlay/usr/local/bin/droiddeck-fex | 144 +++++++++++-- .../usr/local/bin/droiddeck-steam-compat | 70 ++++++- .../usr/local/bin/droiddeck-steam-install | 3 +- .../overlay/usr/local/bin/steam-compatibility | 64 +++++- tools/release/runtime_inputs.py | 2 +- tools/tests/test_droiddeck_fex.py | 194 +++++++++++++++-- tools/tests/test_fakeinput_ring.py | 144 +++++++++++++ tools/tests/test_game_environment.py | 20 +- tools/tests/test_request_fex.py | 4 +- tools/tests/test_steam_compat_live.py | 196 +++++++++++++++++- tools/tests/test_steam_libraries.py | 2 +- tools/tests/test_vulkan_pin.py | 141 +++++++++++++ 17 files changed, 1035 insertions(+), 144 deletions(-) delete mode 100644 tools/linuxfs/fex/thunkaudit.c create mode 100644 tools/linuxfs/fex/vulkanpin.c create mode 100644 tools/tests/test_fakeinput_ring.py create mode 100644 tools/tests/test_vulkan_pin.py diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 71022649c..486b72076 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -75,7 +75,16 @@ static constexpr uint8_t GAMEPAD_AXIS_COUNT = 8; static constexpr uint8_t GAMEPAD_BUTTON_COUNT = 11; static constexpr uint32_t FAKE_INPUT_RING_MAGIC = 0x46494252; static constexpr uint32_t FAKE_INPUT_RING_VERSION = 2; -static constexpr uint32_t FAKE_INPUT_EVENT_SIZE = sizeof(struct input_event); +// Rings hold the 64-bit input_event layout that the arm64 writers use; 32-bit guests convert on read. +struct RingEvent { + int64_t sec; + int64_t usec; + uint16_t type; + uint16_t code; + int32_t value; +}; +static_assert(sizeof(RingEvent) == 24, "ring events must stay ABI-stable"); +static constexpr uint32_t FAKE_INPUT_EVENT_SIZE = sizeof(RingEvent); static constexpr uint32_t FAKE_INPUT_RING_CAPACITY = 4096; static constexpr unsigned int FAKE_INPUT_MAJOR = 13; static constexpr unsigned int FAKE_INPUT_EVENT_MINOR_BASE = 64; @@ -928,14 +937,13 @@ __attribute__((visibility("hidden"))) static void flush_uinput(UinputWriter &w) return; } FakeInputRingHeader *ring = w.ring; - auto *events = reinterpret_cast(reinterpret_cast(ring) + - FAKE_INPUT_RING_HEADER_SIZE); + auto *events = reinterpret_cast(reinterpret_cast(ring) + + FAKE_INPUT_RING_HEADER_SIZE); uint64_t write_seq = __atomic_load_n(&ring->write_seq, __ATOMIC_RELAXED); struct timeval now = {}; gettimeofday(&now, nullptr); - for (struct input_event ev : w.pending) { - ev.time = now; - events[write_seq % FAKE_INPUT_RING_CAPACITY] = ev; + for (const struct input_event &ev : w.pending) { + events[write_seq % FAKE_INPUT_RING_CAPACITY] = {now.tv_sec, now.tv_usec, ev.type, ev.code, ev.value}; write_seq++; if (ev.type == EV_KEY) { for (size_t i = 0; i < kSnapshotButtonCount; i++) @@ -3511,6 +3519,12 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { } else if (type == 0x45 && number == 0x90) { Logger::log("Hooking ioctl EVIOCGRAB for event %s\n", event); return 0; + } else if (type == 0x6A && !strncmp(event, "event", 5)) { + // Older SDL2 builds treat any JSIOCGNAME answer as a joydev node and skip EVIOCGID, then drop the pad as 0000:0000. + Logger::log("Rejecting joydev ioctl %d for event %s\n", number, event); + guard.unlock(); + errno = EINVAL; + return -1; } else if (type == 0x6A && number == 0x1) { Logger::log("Hooking ioctl JSIOCGVERSION for event %s\n", event); int version = JS_VERSION; @@ -3573,7 +3587,7 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { } auto handle = controller->second; FakeController &fake = *handle; - if (count < FAKE_INPUT_EVENT_SIZE) { + if (count < sizeof(struct input_event)) { errno = EINVAL; return -1; } @@ -3591,7 +3605,7 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { return -1; } for (int attempt = 0; attempt < 8; ++attempt) { - size_t requested = count / FAKE_INPUT_EVENT_SIZE; + size_t requested = count / sizeof(struct input_event); if (fake.keyframe_remaining == 0) { SnapshotState snap; if (!read_snapshot(fake.ring, snap)) break; @@ -3610,17 +3624,22 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { FAKE_INPUT_RING_HEADER_SIZE; for (size_t i = 0; i < events; ++i) { size_t index = (fake.read_seq + i) % FAKE_INPUT_RING_CAPACITY; - struct input_event ev; - memcpy(&ev, ring_events + index * FAKE_INPUT_EVENT_SIZE, FAKE_INPUT_EVENT_SIZE); + RingEvent stored; + memcpy(&stored, ring_events + index * FAKE_INPUT_EVENT_SIZE, FAKE_INPUT_EVENT_SIZE); + struct input_event ev = {}; + ev.time.tv_sec = stored.sec; + ev.time.tv_usec = stored.usec; + ev.type = stored.type; + ev.code = stored.code; + ev.value = stored.value; if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code); - memcpy(static_cast(buf) + i * FAKE_INPUT_EVENT_SIZE, &ev, - FAKE_INPUT_EVENT_SIZE); + memcpy(static_cast(buf) + i * sizeof(ev), &ev, sizeof(ev)); } __atomic_thread_fence(__ATOMIC_ACQUIRE); if (snap.sequence != __atomic_load_n(&fake.ring->snapshot_seq, __ATOMIC_RELAXED)) continue; fake.read_seq += events; - return static_cast(events * FAKE_INPUT_EVENT_SIZE); + return static_cast(events * sizeof(struct input_event)); } } @@ -3635,11 +3654,10 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { ev.code = kNeutralEvents[index].code; ev.value = keyframe_value(fake, ev.type, ev.code); if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code); - memcpy(static_cast(buf) + i * FAKE_INPUT_EVENT_SIZE, - &ev, FAKE_INPUT_EVENT_SIZE); + memcpy(static_cast(buf) + i * sizeof(ev), &ev, sizeof(ev)); --fake.keyframe_remaining; } - return static_cast(events * FAKE_INPUT_EVENT_SIZE); + return static_cast(events * sizeof(struct input_event)); } if (nonblock) { errno = EAGAIN; diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 12ea32425..681287c86 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -128,7 +128,7 @@ object SessionFiles { "usr/share/vulkan/implicit_layer.d/droiddeck_vr.json") val fexPreloads = listOf("x86_64", "i386").flatMap { arch -> listOf("libblsession.so", "libfakeinput.so", "libfaultreport.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" } - } + listOf("libthunkaudit.so", "libvulkan-thunk.so", "libwebkitinput.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" } + } + listOf("libvulkan-thunk.so", "libwebkitinput.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" } val optional = (arrayOf( "usr/local/bin/gamescope" to "usr/local/bin/gamescope", "usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime", diff --git a/tools/linuxfs/build-x86-preloads.sh b/tools/linuxfs/build-x86-preloads.sh index 8a43941af..61abcb321 100755 --- a/tools/linuxfs/build-x86-preloads.sh +++ b/tools/linuxfs/build-x86-preloads.sh @@ -26,7 +26,7 @@ for arch in x86_64 i386; do fi done done -for target in x86_64/faultreport i386/faultreport x86_64/thunkaudit; do +for target in x86_64/faultreport i386/faultreport; do arch=${target%/*} name=${target#*/} case $arch in x86_64) bits=-m64 ;; @@ -51,11 +51,14 @@ if objdump -p "$lib" | grep -q NEEDED || [ "$(nm -D --undefined-only "$lib" | aw fi stub=$(mktemp -d) gcc -m64 -shared -nostdlib -Wl,-soname,/usr/lib/x86_64-linux-gnu/libvulkan.so.1 -o "$stub/libvulkan.so.1" -x c /dev/null -gcc -m64 -shared -nostdlib -Wl,-z,nodelete -Wl,--no-as-needed \ - -o "$out/x86_64/libvulkan-thunk.so" -x c /dev/null -x none "$stub/libvulkan.so.1" +lib=$out/x86_64/libvulkan-thunk.so +gcc -m64 -shared -fPIC -O2 -Wall -Wextra -fvisibility=hidden -Wl,-soname,libvulkan.so.1 -Wl,-z,nodelete -Wl,--no-as-needed \ + -o "$lib" tools/linuxfs/fex/vulkanpin.c "$stub/libvulkan.so.1" -ldl rm -rf "$stub" -if ! objdump -p "$out/x86_64/libvulkan-thunk.so" | grep -q 'NEEDED *\/usr/lib/x86_64-linux-gnu/libvulkan\.so\.1$' \ - || ! objdump -p "$out/x86_64/libvulkan-thunk.so" | grep -q 'FLAGS_1 *0x0*8$'; then - echo "$out/x86_64/libvulkan-thunk.so must be nodelete and need only the Vulkan thunk" >&2 +strip --strip-unneeded "$lib" +newest=$(objdump -T "$lib" | grep -o 'GLIBC_2\.[0-9]*' | sort -u -t. -k2,2n | tail -1) +if ! objdump -p "$lib" | grep -q 'NEEDED *\/usr/lib/x86_64-linux-gnu/libvulkan\.so\.1$' \ + || ! objdump -p "$lib" | grep -q 'FLAGS_1 *0x0*8$' || [ "${newest#GLIBC_2.}" -gt 31 ]; then + echo "$lib must be nodelete, need the Vulkan thunk and run on glibc 2.31" >&2 exit 1 fi diff --git a/tools/linuxfs/fex/thunkaudit.c b/tools/linuxfs/fex/thunkaudit.c deleted file mode 100644 index 413ac73ba..000000000 --- a/tools/linuxfs/fex/thunkaudit.c +++ /dev/null @@ -1,40 +0,0 @@ -#if !defined(__x86_64__) -#error thunkaudit is built for x86_64 guests only -#endif - -#define LA_SER_ORIG 0x01 - -static const char vulkan_thunk[] = "/usr/lib/x86_64-linux-gnu/libvulkan.so.1"; -static const char vulkan_pin[] = "/usr/lib/x86_64-linux-gnu/droiddeck/libvulkan-thunk.so"; - -static int same(const char *a, const char *b) -{ - while (*a && *a == *b) { - a++; - b++; - } - return *a == *b; -} - -static const char *base_name(const char *path) -{ - const char *name = path; - for (; *path; path++) - if (*path == '/') - name = path + 1; - return name; -} - -__attribute__((visibility("default"))) unsigned int la_version(unsigned int version) -{ - return version; -} - -__attribute__((visibility("default"))) char *la_objsearch(const char *name, unsigned long *cookie, unsigned int flag) -{ - const char *file = base_name(name); - (void)cookie; - if (flag == LA_SER_ORIG && !same(name, vulkan_thunk) && (same(file, "libvulkan.so.1") || same(file, "libvulkan.so"))) - return (char *)vulkan_pin; - return (char *)name; -} diff --git a/tools/linuxfs/fex/vulkanpin.c b/tools/linuxfs/fex/vulkanpin.c new file mode 100644 index 000000000..45aeb9edb --- /dev/null +++ b/tools/linuxfs/fex/vulkanpin.c @@ -0,0 +1,88 @@ +#include +#include +#include + +#ifndef VULKAN_THUNK +#define VULKAN_THUNK "/usr/lib/x86_64-linux-gnu/libvulkan.so.1" +#endif + +#define VK_INCOMPLETE 5 +#define VK_ERROR_INITIALIZATION_FAILED (-3) +#define EXTENSIONS 128 + +typedef int VkResult; +typedef void (*Function)(void); +typedef struct { + char extensionName[256]; + unsigned int specVersion; +} VkExtensionProperties; +typedef VkResult (*Enumerate)(const char *, unsigned int *, VkExtensionProperties *); +typedef Function (*ProcAddr)(void *, const char *); + +/* The driver cannot enumerate devices for an instance with VK_KHR_display, and gamescope's WSI layer aborts on that. */ +static const char *const hidden_extensions[] = { + "VK_KHR_display", + "VK_KHR_get_display_properties2", + "VK_EXT_direct_mode_display", + "VK_EXT_acquire_xlib_display", + "VK_EXT_acquire_drm_display", + "VK_EXT_display_surface_counter", +}; + +static void *thunk(const char *name) +{ + void *library = dlopen(VULKAN_THUNK, RTLD_NOW | RTLD_NOLOAD); + void *found = library ? dlsym(library, name) : NULL; + if (library) + dlclose(library); + return found; +} + +static int hidden(const char *name) +{ + for (size_t i = 0; i < sizeof(hidden_extensions) / sizeof(hidden_extensions[0]); i++) + if (!strcmp(name, hidden_extensions[i])) + return 1; + return 0; +} + +__attribute__((visibility("default"))) VkResult vkEnumerateInstanceExtensionProperties(const char *layer, unsigned int *count, + VkExtensionProperties *properties) +{ + static Enumerate real; + VkExtensionProperties all[EXTENSIONS]; + unsigned int total = EXTENSIONS, kept = 0, room = properties ? *count : 0; + VkResult result; + if (!real) + real = (Enumerate)thunk("vkEnumerateInstanceExtensionProperties"); + if (!real) + return VK_ERROR_INITIALIZATION_FAILED; + result = real(layer, &total, all); + if (result < 0) + return result; + for (unsigned int i = 0; i < total; i++) { + if (hidden(all[i].extensionName)) + continue; + if (properties && kept < room) + memcpy(&properties[kept], &all[i], sizeof(all[i])); + kept++; + } + if (!properties) { + *count = kept; + return result; + } + *count = kept < room ? kept : room; + return kept > room ? VK_INCOMPLETE : result; +} + +__attribute__((visibility("default"))) Function vkGetInstanceProcAddr(void *instance, const char *name) +{ + static ProcAddr real; + if (!strcmp(name, "vkGetInstanceProcAddr")) + return (Function)vkGetInstanceProcAddr; + if (!strcmp(name, "vkEnumerateInstanceExtensionProperties")) + return (Function)vkEnumerateInstanceExtensionProperties; + if (!real) + real = (ProcAddr)thunk("vkGetInstanceProcAddr"); + return real ? real(instance, name) : NULL; +} diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex index c05ab928b..0de377ef0 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex @@ -28,15 +28,25 @@ URUNTIME = "/usr/local/lib/droiddeck/uruntime" HOST_PRELOAD = "/etc/ld.so.preload" FEX_APPID = "3127680" SNIPER_APPID = "1628350" -INSTALL_DIRS = {FEX_APPID: "FEX-Emu", SNIPER_APPID: "SteamLinuxRuntime_sniper"} +SLR4_APPID = "4183110" +INSTALL_DIRS = {FEX_APPID: "FEX-Emu", SNIPER_APPID: "SteamLinuxRuntime_sniper", SLR4_APPID: "SteamLinuxRuntime_4"} MARKER = ".droiddeck-fex" LAYOUT = 1 +ROOTFS_LAYOUT = 1 +ARCH_DIRS = ("x86_64-linux-gnu", "i386-linux-gnu") +INTERPRETERS = ("ld-linux-x86-64.so.2", "ld-linux.so.2") +PROVIDED = frozenset(( + "ld-linux-x86-64.so.2", "ld-linux.so.2", "libc.so.6", "libm.so.6", "libmvec.so.1", "libpthread.so.0", "libdl.so.2", + "librt.so.1", "libutil.so.1", "libresolv.so.2", "libanl.so.1", "libBrokenLocale.so.1", "libnsl.so.1", + "libnss_compat.so.2", "libnss_dns.so.2", "libnss_files.so.2", "libnss_hesiod.so.2", "libthread_db.so.1", + "libmemusage.so", "libpcprofile.so", "libc_malloc_debug.so.0", "gconv", "libstdc++.so.6", "libgcc_s.so.1", +)) +GLIBC_BUILD = re.compile(r"^(?:ld|lib[A-Za-z_]+)-2\.[0-9]+\.so$|^libSegFault\.so$") GUEST_LIBDIRS = (("x86_64", "lib/x86_64-linux-gnu"), ("i386", "lib/i386-linux-gnu")) PRELOAD_NAMES = ("libblsession.so", "libfakeinput.so") FAULT_REPORT = "libfaultreport.so" -THUNK_AUDIT = "libthunkaudit.so" -THUNK_AUDIT_GUEST = "/usr/lib/x86_64-linux-gnu/droiddeck/" + THUNK_AUDIT VULKAN_PIN = "libvulkan-thunk.so" +VULKAN_PIN_DIR = "lib/x86_64-linux-gnu/droiddeck/vulkan" VULKAN_THUNK = "libvulkan-guest.so" RUNTIME_LIBRARIES = (("libc.so.6", b"GLIBC_"), ("libstdc++.so.6", b"GLIBCXX_")) VERSION_NAME = re.compile(rb"GLIBC(?:XX)?_[0-9][0-9.]*[0-9]") @@ -164,15 +174,15 @@ def platform_version(path): return tuple(int(part) for part in re.findall(r"\d+", os.path.basename(path))) -def runtime_platform(): - runtime = app_dir(SNIPER_APPID) +def runtime_platform(appid=SNIPER_APPID, prefix="sniper_platform_"): + runtime = app_dir(appid) if not runtime: return None try: entries = os.listdir(runtime) except OSError: return None - platforms = [os.path.join(runtime, entry) for entry in entries if entry.startswith("sniper_platform_")] + platforms = [os.path.join(runtime, entry) for entry in entries if entry.startswith(prefix)] platforms = [path for path in platforms if os.path.isdir(os.path.join(path, "files")) and os.path.isfile(os.path.join(path, "usr-mtree.txt.gz"))] return max(platforms, key=platform_version, default=None) @@ -234,6 +244,75 @@ def materialize(platform): return True +def graphics_platform(): + return runtime_platform(SLR4_APPID, "steamrt4_platform_") + + +def provides_newer(base, provider): + for name, prefix in RUNTIME_LIBRARIES: + ours = {v for v in version_names(os.path.join(base, "files/lib/x86_64-linux-gnu", name)) if v.startswith(prefix)} + theirs = {v for v in version_names(os.path.join(provider, "files/lib/x86_64-linux-gnu", name)) if v.startswith(prefix)} + if not ours or not theirs or not ours < theirs: + return False + return True + + +def mirror(source, provider, name, target): + if provider and name in PROVIDED and os.path.lexists(os.path.join(provider, name)): + os.symlink(os.path.realpath(os.path.join(provider, name)), os.path.join(target, name)) + return + path = os.path.join(source, name) + link = os.readlink(path) if os.path.islink(path) else None + if link and GLIBC_BUILD.match(os.path.basename(link)): + if name not in INTERPRETERS: + return + link = os.path.join(os.path.dirname(link), name) + if provider and GLIBC_BUILD.match(name): + return + if link and not link.startswith("/"): + os.symlink(link, os.path.join(target, name)) + else: + os.symlink(os.path.realpath(path), os.path.join(target, name)) + + +def build_rootfs(base, provider): + """The runtime with glibc and libstdc++ taken from a newer one, which FEX's GL and Vulkan thunks are built against.""" + base_files, provider_files = os.path.join(base, "files"), os.path.join(provider, "files") + rootfs = os.path.join(STATE, "rootfs") + stamp = "%d\n%s\n%s%s\n%s" % (ROOTFS_LAYOUT, os.path.realpath(base), read_text(os.path.join(base_files, MARKER)) or "", + os.path.realpath(provider), read_text(os.path.join(provider_files, MARKER)) or "") + if read_text(os.path.join(rootfs, MARKER)) == stamp: + return False + for entry in os.listdir(STATE): + if entry.startswith("rootfs."): + shutil.rmtree(os.path.join(STATE, entry), ignore_errors=True) + tmp = "%s.%d.tmp" % (rootfs, os.getpid()) + os.makedirs(tmp) + for entry in os.listdir(base_files): + if entry not in ("lib", "lib64", "usr", MARKER): + os.symlink(os.path.realpath(os.path.join(base_files, entry)), os.path.join(tmp, entry)) + os.symlink(".", os.path.join(tmp, "usr")) + for top in ("lib", "lib64"): + source = os.path.join(base_files, top) + os.makedirs(os.path.join(tmp, top)) + for entry in os.listdir(source) if os.path.isdir(source) else (): + path = os.path.join(source, entry) + if entry in ARCH_DIRS and os.path.isdir(path) and not os.path.islink(path): + provided = os.path.join(provider_files, top, entry) + os.makedirs(os.path.join(tmp, top, entry)) + for name in set(os.listdir(path)) | {name for name in PROVIDED if os.path.lexists(os.path.join(provided, name))}: + mirror(path, provided, name, os.path.join(tmp, top, entry)) + else: + mirror(source, None, entry, os.path.join(tmp, top)) + write_if_changed(os.path.join(tmp, MARKER), stamp) + old = "%s.%d.old" % (rootfs, os.getpid()) + if os.path.lexists(rootfs): + os.rename(rootfs, old) + os.rename(tmp, rootfs) + shutil.rmtree(old, ignore_errors=True) + return True + + def host_preloads(): listed = read_text(HOST_PRELOAD) or "" return [name for name in PRELOAD_NAMES if name in listed] @@ -247,10 +326,14 @@ def install_preloads(root): if os.path.isfile(source): copy_if_changed(source, os.path.join(root, libdir, "droiddeck", name)) shipped.setdefault(name, set()).add(arch) - for name in (THUNK_AUDIT, VULKAN_PIN): - source = os.path.join(PRELOADS, "x86_64", name) - if os.path.isfile(source): - copy_if_changed(source, os.path.join(root, "lib/x86_64-linux-gnu/droiddeck", name)) + pin = os.path.join(PRELOADS, "x86_64", VULKAN_PIN) + if os.path.isfile(pin): + copy_if_changed(pin, os.path.join(root, VULKAN_PIN_DIR, "libvulkan.so.1")) + alias = os.path.join(root, VULKAN_PIN_DIR, "libvulkan.so") + if not os.path.islink(alias) or os.readlink(alias) != "libvulkan.so.1": + if os.path.lexists(alias): + os.unlink(alias) + os.symlink("libvulkan.so.1", alias) wanted = [name for name in host_preloads() if name in shipped] if len(shipped.get(FAULT_REPORT, ())) == len(GUEST_LIBDIRS): wanted.append(FAULT_REPORT) @@ -270,14 +353,16 @@ def write_config(fex, root): def link_steam_sdk(): for sdk, candidates in (("sdk64", ("linux64", "ubuntu12_64")), ("sdk32", ("linux32", "ubuntu12_32"))): link = os.path.join(HOME, ".steam", sdk) - if os.path.lexists(link): - continue want = "x86_64" if sdk == "sdk64" else "i386" + if elf_arch(os.path.join(link, "steamclient.so")) == want: + continue for candidate in candidates: directory = os.path.join(STEAM_ROOT, candidate) if elf_arch(os.path.join(directory, "steamclient.so")) == want: os.makedirs(os.path.dirname(link), exist_ok=True) - os.symlink(directory, link) + tmp = "%s.%d.tmp" % (link, os.getpid()) + os.symlink(directory, tmp) + os.replace(tmp, link) break @@ -308,7 +393,7 @@ def request_install(): return [] -def describe(fex, platform): +def describe(fex, platform, graphics=None): return { "fex": fex["bin"] if fex else None, "server": fex["server"] if fex else None, @@ -317,6 +402,7 @@ def describe(fex, platform): "steamclient64": os.path.realpath(os.path.join(HOME, ".steam", "sdk64", "steamclient.so")) if elf_arch(os.path.join(HOME, ".steam", "sdk64", "steamclient.so")) == "x86_64" else None, "ready": bool(fex and fex["server"] and platform), + "graphics": graphics, } @@ -346,13 +432,27 @@ def prepare(): if materialize(platform): say("prepared %s as the x86 runtime" % os.path.basename(platform)) install_preloads(root) + provider = graphics_platform() + if provider and materialize(provider): + say("prepared %s for the x86 runtime's C and C++ libraries" % os.path.basename(provider)) + if not provider: + say("Steam Linux Runtime 4.0 (Steam app %s) is not installed yet; x86 games may not find their graphics drivers" + % SLR4_APPID) + elif not provides_newer(platform, provider): + say("%s has no newer C and C++ libraries than %s" % (os.path.basename(provider), os.path.basename(platform))) + provider = None + else: + if build_rootfs(platform, provider): + say("built the x86 runtime from %s with the C and C++ libraries of %s" + % (os.path.basename(platform), os.path.basename(provider))) + root = os.path.join(STATE, "rootfs") write_config(fex, root) fex["rootfs"] = root try: link_steam_sdk() except OSError: pass - status = describe(fex, platform) + status = describe(fex, platform, provider) write_if_changed(STATUS_FILE, json.dumps(status, indent=2) + "\n") with contextlib.suppress(OSError): os.unlink(WANTED) @@ -430,6 +530,8 @@ def guest_environment(fex, env): result["STEAM_COMPAT_DATA_PATH"] = short result.setdefault("FEX_SILENTLOG", "0") result.setdefault("FEX_OUTPUTLOG", "stderr") + # Mono's incremental Boehm collector corrupts its heap under FEX, so Unity games crash at their first collections. + result.setdefault("GC_DISABLE_INCREMENTAL", "1") if fex["portable"]: result["FEX_PORTABLE"] = "1" else: @@ -445,7 +547,7 @@ def guest_environment(fex, env): result["FEX_THUNKCONFIG"] = os.path.join(CONFIG_DIR, "ThunksConfig.json") preload = [part for part in split_paths(result.get("LD_PRELOAD")) if os.path.exists(part) and not is_arm(part)] library_path = [part for part in (result.get("LD_LIBRARY_PATH") or "").split(":") - if part and "steamrtarm64" not in part and "aarch64" not in part] + if part and "arm64" not in part and "aarch64" not in part] for name, parts in (("LD_PRELOAD", preload), ("LD_LIBRARY_PATH", library_path)): if parts: result[name] = ":".join(parts) @@ -483,6 +585,7 @@ def vulkan_thunk_loads(fex, folder): needed = version_names(os.path.join(fex["guest_thunks"], VULKAN_THUNK)) if not needed: return False + needed |= version_names(os.path.join(PRELOADS, "x86_64", VULKAN_PIN)) found = bundled_libraries(folder) for name, prefix in RUNTIME_LIBRARIES: library = found.get(name) or os.path.join(fex["rootfs"], "lib/x86_64-linux-gnu", name) @@ -509,9 +612,10 @@ def launch(command, env, mode, decide=None): x86_64 = elf_arch(program) != "i386" thunks = "on" if "FEX_THUNKCONFIG" in guest else "off" folder = decide if decide and os.path.isdir(decide) else os.path.dirname(program) - if thunks == "on" and x86_64 and all(os.path.isfile(os.path.join(PRELOADS, "x86_64", name)) for name in (THUNK_AUDIT, VULKAN_PIN)) \ + if thunks == "on" and x86_64 and os.path.isfile(os.path.join(PRELOADS, "x86_64", VULKAN_PIN)) \ and vulkan_thunk_loads(fex, folder): - guest["LD_AUDIT"] = ":".join([THUNK_AUDIT_GUEST] + split_paths(guest.get("LD_AUDIT"))) + # Any LD_AUDIT module makes Mono's garbage collector crash under FEX, so the Vulkan thunk is pinned by search path. + guest["LD_LIBRARY_PATH"] = ":".join(["/usr/" + VULKAN_PIN_DIR] + split_paths(guest.get("LD_LIBRARY_PATH"))) thunks = "on, its own Vulkan loader too" say("running %s with %s (runtime %s, thunks %s)" % (program, fex["bin"], guest.get("FEX_ROOTFS", "none"), thunks)) return os.execve(fex["bin"], [fex["bin"], program] + command[1:], guest) @@ -563,7 +667,7 @@ def extracted(): def want_fex(): with locked(): - status = describe(fex_install(), runtime_platform()) + status = describe(fex_install(), runtime_platform(), graphics_platform()) if not status["ready"]: request_install() write_if_changed(STATUS_FILE, json.dumps(status, indent=2) + "\n") @@ -679,7 +783,7 @@ def main(argv): return 0 if prepare() else 1 if action == "status": with locked(): - status = describe(fex_install(), runtime_platform()) + status = describe(fex_install(), runtime_platform(), graphics_platform()) write_if_changed(STATUS_FILE, json.dumps(status, indent=2) + "\n") print(json.dumps(status, indent=2)) return 0 if status["ready"] else 1 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat index 3cb4c9cf3..04392d060 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat @@ -126,6 +126,29 @@ INSTALL_JS = r""" return "installed"; })() """.replace("__BINDING__", BINDING) +TOOLS_JS = r""" +(() => { + if (window.__droiddeckTools) return "present"; + const installs = SteamClient.Installs, downloads = SteamClient.Downloads; + if (typeof installs?.OpenInstallWizard !== "function" || typeof installs?.RegisterForShowInstallWizard !== "function" || + typeof installs?.ContinueInstall !== "function" || typeof downloads?.QueueAppUpdate !== "function") + throw new Error("the client's install calls are not ready"); + const self = { asked: new Map() }; + installs.RegisterForShowInstallWizard((state) => { + const app = Number(state?.currentAppID); + if (state?.eInstallState === 7 && Date.now() - (self.asked.get(app) || 0) < 120000) { + self.asked.delete(app); + installs.ContinueInstall(); + } + }); + self.install = (app) => { self.asked.set(app, Date.now()); installs.OpenInstallWizard([app], "0"); return true; }; + self.update = (app) => { downloads.QueueAppUpdate(app, "0"); return true; }; + window.__droiddeckTools = self; + return "installed"; +})() +""" +TOOL_CHECK_SECONDS = 60 +TOOL_RETRY_SECONDS = 300 SWITCH_BINDING = "droiddeckSwitchToDesktop" SWITCH_JS = r""" (() => { @@ -397,6 +420,9 @@ class Page: raise RuntimeError((details.get("exception") or {}).get("description") or details.get("text") or "script failed") return result.get("result", {}).get("value") + def tools(self, call): + return self.evaluate("%s;\nwindow.__droiddeckTools.%s" % (TOOLS_JS, call)) + def compat(self, call): return self.evaluate("%s;\nwindow.__droiddeckCompat.%s" % (INSTALL_JS, call)) @@ -481,6 +507,8 @@ class Helper: self.known = set() self.installed = None self.fex_asked = set() + self.tools_asked = {} + self.tools_checked = None self.vr_launch = None def attach(self): @@ -524,7 +552,8 @@ class Helper: prior_auto = state.get("auto", {}) if not isinstance(prior_auto, dict): prior_auto = {} - prior_native = state.get("native", ()) + # "native" from earlier builds also lists dual-platform games nobody chose to run natively. + prior_native = state.get("native_choices", ()) native_choices = set(prior_native if isinstance(prior_native, (list, tuple, set)) else ()) apps = sorted(set(installed) | set(picks) | set(prior_auto) | native_choices, key=COMPAT["app_order"]) overrides = COMPAT["requested_games"]( @@ -538,12 +567,19 @@ class Helper: if raw_mapping is None: for app in native_choices: current[app] = "" + for app in set(native) - native_choices: + if not current.get(app): + current.pop(app, None) else: for app in set(native) | native_choices: if app in raw_mapping: - current[app] = raw_mapping[app] + # The client shows an explicit native choice as the tool it inherits, and writes config.vdf late. + if raw_mapping[app] == "" or current.get(app) not in self.known: + current[app] = raw_mapping[app] elif app in prior_auto or app in native_choices: current[app] = "" + elif not current.get(app): + current.pop(app, None) changes, auto = COMPAT["plan_mapping"]( current, installed, [t["name"] for t in live], default, COMPAT["followers"](state, current), native, picks, overrides, @@ -554,18 +590,36 @@ class Helper: tool if tool is not None else "Steam native default")) state.update( auto={app: tool for app, tool in auto.items() if tool is not COMPAT["NATIVE_CHOICE"]}, - native=sorted(app for app, tool in auto.items() if tool is COMPAT["NATIVE_CHOICE"]), + native_choices=sorted(app for app, tool in auto.items() if tool is COMPAT["NATIVE_CHOICE"]), tools=catalog, live=True) + state.pop("native", None) if waiting: state["pending"] = wanted say("%s is new to the running client; it applies when Steam next starts" % wanted) else: state.pop("pending", None) COMPAT["write_json"](state_file, state) - if (any(app in installed and tool == COMPAT["FEX_TOOL"] for app, tool in native.items()) or - os.path.exists(COMPAT["fex_wanted_marker"]())): - for app in COMPAT["request_fex"](self.steam_root, self.fex_asked): - say("asked the client to install %s for x86 Linux titles and programs" % app) + + def ensure_tools(self): + """Proton, FEX and the runtimes FEX needs are fetched as soon as the client is up, never left pending.""" + now = time.monotonic() + if self.tools_checked is not None and now - self.tools_checked < TOOL_CHECK_SECONDS: + return + self.tools_checked = now + if COMPAT["game_running"](): + return + for app, action in COMPAT["tool_downloads"](self.steam_root): + if now - self.tools_asked.get(app, -TOOL_RETRY_SECONDS) < TOOL_RETRY_SECONDS: + continue + self.tools_asked[app] = now + try: + self.page.tools("%s(%d)" % (action, int(app))) + say("asked the client to %s %s" % (action, app)) + except Exception as e: + say("the client could not %s %s (%s)" % (action, app, e)) + if action == "install": + for asked in COMPAT["request_fex"](self.steam_root, self.fex_asked): + say("asked the client to install %s through steam://install" % asked) def request_waiting(self): request = COMPAT["read_json"](COMPAT["state_path"]("request.json")) @@ -592,6 +646,7 @@ class Helper: f.write("%d\n" % os.getpid()) say("attached to the client on port %d" % self.port) self.reconcile() + self.ensure_tools() while True: sources = [self.page.ws] + ([watcher] if watcher.fileno() >= 0 else []) ready = [self.page.ws] if self.page.ws.pending() else select.select(sources, [], [], 30 if watcher.fileno() >= 0 else 2)[0] @@ -606,6 +661,7 @@ class Helper: watcher.changed() self.drain() self.reconcile() + self.ensure_tools() def detach(): diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install index 7e9b4e073..3ce263c0d 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install @@ -111,7 +111,8 @@ mkdir -p "$HOME/.steam" ln -sfn "$steam_root" "$HOME/.steam/root" ln -sfn "$steam_root" "$HOME/.steam/steam" ln -sfn "$steam_root/steamrtarm64" "$HOME/.steam/bin64" -ln -sfn "$steam_root/linuxarm64" "$HOME/.steam/sdk64" +# sdk64 is where x86_64 Steamworks looks for steamclient.so; arm64 builds look in sdkarm64. +ln -sfn "$steam_root/linux64" "$HOME/.steam/sdk64" ln -sfn "$steam_root/linuxarm64" "$HOME/.steam/sdkarm64" ln -sfn "$steam_root/steamrtarm64" "$HOME/.steam/binarm64" ln -sfn "$steam_root/linux32" "$HOME/.steam/sdk32" diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index e1ce3adeb..cc68575e5 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -21,7 +21,11 @@ FEX_LAUNCHER = "droiddeck-fex-tool" FEX_FILES = ("toolmanifest.vdf", "compatibilitytool.vdf", FEX_LAUNCHER) FEX_APPID = "3127680" SNIPER_APPID = "1628350" -FEX_DEPENDENCIES = (FEX_APPID, SNIPER_APPID) +SLR4_APPID = "4183110" +FEX_DEPENDENCIES = (FEX_APPID, SNIPER_APPID, SLR4_APPID) +REQUIRED_TOOLS = (PROTON_ARM64_APPID,) + FEX_DEPENDENCIES +FULLY_INSTALLED = 4 +UPDATE_REQUIRED = 2 REQUEST_HANDOFF_SECONDS = 30 PLAYABLE_TYPES = frozenset(("game", "demo", "application", "beta")) COMPAT_DIR = os.path.join(os.environ.get("HOME", "/root"), ".local", "share", "droiddeck-compat") @@ -850,6 +854,36 @@ def native_targets(steam_root, apps): return targets +def app_manifest(steam_root, app): + for library in libraries(steam_root): + try: + with open(os.path.join(library, "steamapps", "appmanifest_%s.acf" % app), encoding="utf-8", errors="replace") as f: + return tokenize(f.read()) + except OSError: + continue + return None + + +def manifest_flags(tokens): + for i in range(len(tokens) - 1): + if tokens[i].lower() == '"stateflags"': + value = tokens[i + 1].strip('"') + return int(value) if value.isdigit() else 0 + return 0 + + +def tool_downloads(steam_root): + """(app, "install" or "update") for each tool the client has yet to install or bring up to date.""" + wanted = [] + for app in REQUIRED_TOOLS: + tokens = app_manifest(steam_root, app) + if tokens is None: + wanted.append((app, "install")) + elif not manifest_flags(tokens) & FULLY_INSTALLED or manifest_flags(tokens) & UPDATE_REQUIRED: + wanted.append((app, "update")) + return wanted + + def fex_missing(steam_root): missing = [] for app in FEX_DEPENDENCIES: @@ -1174,6 +1208,21 @@ def steam_running(): return False +def game_running(): + try: + pids = [p for p in os.listdir("/proc") if p.isdigit()] + except OSError: + return False + for pid in pids: + try: + with open("/proc/%s/cmdline" % pid, "rb") as f: + if b"\0SteamLaunch\0AppId=" in f.read(): + return True + except OSError: + continue + return False + + def live_helper_running(directory=None): try: with open(state_path("live.pid", directory)) as f: @@ -1209,21 +1258,22 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non default = state.get("default") if state.get("default") in names else TOOL steam_root = os.path.dirname(os.path.dirname(os.path.abspath(config_path))) overrides = requested_games(game_requests(directory), catalog) - native = native_targets(steam_root, set(apps) | set(picks)) + managed = state.get("auto", {}).keys() if isinstance(state.get("auto"), dict) else () + native_choices = state.get("native_choices", ()) + native = native_targets(steam_root, set(apps) | set(picks) | set(managed) | set(native_choices)) if linux_tool != FEX_TOOL: native = {app: linux_tool if tool == FEX_TOOL else tool for app, tool in native.items()} changes, auto = plan_mapping( - current, apps, names, default, followers(state, current), - native, picks, - overrides, state.get("auto", {}).keys() if isinstance(state.get("auto"), dict) else (), - state.get("native", ())) + current, apps, names, default, followers(state, current), native, picks, + overrides, managed, native_choices) for app, tool in changes.items(): set_mapping(tokens, mapping, app, tool) text = serialize(tokens) if text != serialize(tokenize(original)): write_changed(config_path, text) state["auto"] = {app: tool for app, tool in auto.items() if tool is not NATIVE_CHOICE} - state["native"] = sorted(app for app, tool in auto.items() if tool is NATIVE_CHOICE) + state["native_choices"] = sorted(app for app, tool in auto.items() if tool is NATIVE_CHOICE) + state.pop("native", None) state["tools"] = catalog state.setdefault("default", default) write_json(state_path("state.json", directory), state) diff --git a/tools/release/runtime_inputs.py b/tools/release/runtime_inputs.py index 4eb0ed2da..a539e46c4 100644 --- a/tools/release/runtime_inputs.py +++ b/tools/release/runtime_inputs.py @@ -71,7 +71,7 @@ def required(): for arch in ['x86_64', 'i386']: paths += ['assets/linuxfs/' + arch + '/' + lib for lib in ['libfakeinput.so', 'libblsession.so']] paths += ['assets/linuxfs/x86_64/' + lib for lib in - ['libfaultreport.so', 'libthunkaudit.so', 'libvulkan-thunk.so', 'libwebkitinput.so']] + ['libfaultreport.so', 'libvulkan-thunk.so', 'libwebkitinput.so']] paths += ['assets/linuxfs/i386/libfaultreport.so'] paths += ['assets/linuxfs/usr/local/lib/droiddeck-msitools/' + name for name in ['msiinfo', 'cabextract', '7z', '7z.so', 'libmsi-1.0.so.0', 'libgsf-1.so.114', 'libgcab-1.0.so.0']] diff --git a/tools/tests/test_droiddeck_fex.py b/tools/tests/test_droiddeck_fex.py index c9aa2200e..0af8e7f4f 100644 --- a/tools/tests/test_droiddeck_fex.py +++ b/tools/tests/test_droiddeck_fex.py @@ -92,6 +92,38 @@ class FexTestCase(unittest.TestCase): self.write(self.steam / "steamapps/appmanifest_1628350.acf", '"AppState" { "installdir" "SteamLinuxRuntime_sniper" }') return platform + def graphics(self, sniper, libc=b"GLIBC_2.2.5 GLIBC_2.31 GLIBC_2.34 GLIBC_2.41", libstdcxx=b"GLIBCXX_3.4.28 GLIBCXX_3.4.33"): + files = Path(sniper) / "files" + x86 = files / "lib/x86_64-linux-gnu" + self.write(x86 / "libc-2.31.so", b"GLIBC_2.2.5 GLIBC_2.31") + os.unlink(x86 / "libc.so.6") + os.symlink("libc-2.31.so", x86 / "libc.so.6") + self.write(x86 / "ld-2.31.so", b"old ld") + os.symlink("ld-2.31.so", x86 / "ld-linux-x86-64.so.2") + self.write(x86 / "libstdc++.so.6", b"GLIBCXX_3.4.28") + self.write(x86 / "libSegFault.so", b"old") + self.write(x86 / "libSDL2-2.0.so.0", b"sdl") + self.write(x86 / "libfoo.so.1.2", b"foo") + os.symlink("libfoo.so.1.2", x86 / "libfoo.so.1") + self.write(x86 / "gconv/OLD.so", b"old gconv") + self.write(files / "lib/i386-linux-gnu/libc.so.6", b"GLIBC_2.31") + self.write(files / "lib/i386-linux-gnu/ld-2.31.so", b"old ld32") + os.symlink("i386-linux-gnu/ld-2.31.so", files / "lib/ld-linux.so.2") + os.unlink(files / "lib64/ld-linux-x86-64.so.2") + os.symlink("../lib/x86_64-linux-gnu/ld-2.31.so", files / "lib64/ld-linux-x86-64.so.2") + platform = self.steam / "steamapps/common/SteamLinuxRuntime_4/steamrt4_platform_4.0.20260928.262390" + provided = platform / "files/lib/x86_64-linux-gnu" + self.write(provided / "libc.so.6", libc) + self.write(provided / "libstdc++.so.6", libstdcxx) + self.write(provided / "ld-linux-x86-64.so.2", b"new ld") + self.write(provided / "gconv/NEW.so", b"new gconv") + self.write(platform / "files/lib/i386-linux-gnu/libc.so.6", b"GLIBC_2.41") + self.write(platform / "files/lib/i386-linux-gnu/ld-linux.so.2", b"new ld32") + with gzip.open(platform / "usr-mtree.txt.gz", "wt") as out: + out.write("#mtree\n. type=dir\n") + self.write(self.steam / "steamapps/appmanifest_4183110.acf", '"AppState" { "installdir" "SteamLinuxRuntime_4" }') + return platform + def quiet(self, function, *args): with contextlib.redirect_stderr(io.StringIO()) as err: return function(*args), err.getvalue() @@ -198,12 +230,11 @@ class PrepareTest(FexTestCase): for arch in ("x86_64", "i386"): for name in ("libblsession.so", "libfakeinput.so"): self.write(self.tmp / "preloads" / arch / name, arch.encode() + name.encode()) - self.write(self.tmp / "preloads/x86_64/libthunkaudit.so", b"audit") self.write(self.tmp / "preloads/x86_64/libvulkan-thunk.so", b"pin") self.write(self.tmp / "ld.so.preload", "/usr/local/lib/libblsession.so\n/usr/local/lib/libfakeinput.so\n") FEX["install_preloads"](str(root)) - self.assertEqual((root / "lib/x86_64-linux-gnu/droiddeck/libthunkaudit.so").read_bytes(), b"audit") - self.assertEqual((root / "lib/x86_64-linux-gnu/droiddeck/libvulkan-thunk.so").read_bytes(), b"pin") + self.assertEqual((root / "lib/x86_64-linux-gnu/droiddeck/vulkan/libvulkan.so.1").read_bytes(), b"pin") + self.assertEqual(os.readlink(root / "lib/x86_64-linux-gnu/droiddeck/vulkan/libvulkan.so"), "libvulkan.so.1") self.assertEqual((root / "etc/ld.so.preload").read_text(), "/usr/$LIB/droiddeck/libblsession.so\n/usr/$LIB/droiddeck/libfakeinput.so\n") self.assertEqual((root / "lib/i386-linux-gnu/droiddeck/libfakeinput.so").read_bytes(), b"i386libfakeinput.so") @@ -252,7 +283,71 @@ class PrepareTest(FexTestCase): status = json.loads((self.home / ".local/share/droiddeck-fex/status.json").read_text()) self.assertEqual((status["ready"], status["runtime"], status["thunks"]), (True, str(platform), True)) self.assertFalse((self.home / ".config/droiddeck/fex-wanted").exists()) - self.assertEqual(self.quiet(FEX["prepare"])[1], "") + self.assertNotIn("prepared", self.quiet(FEX["prepare"])[1]) + + def test_a_newer_runtime_supplies_the_c_and_cxx_libraries(self): + self.fex_tool() + sniper = self.runtime() + FEX["materialize"](str(sniper)) + provider = self.graphics(sniper) + fex, said = self.quiet(FEX["prepare"]) + root = self.home / ".local/share/droiddeck-fex/rootfs" + self.assertIn("built the x86 runtime from sniper_platform_", said) + self.assertEqual(fex["rootfs"], str(root)) + config = json.loads((self.home / ".local/share/droiddeck-fex/config/Config.json").read_text()) + self.assertEqual(config["Config"]["RootFS"], str(root)) + x86 = root / "lib/x86_64-linux-gnu" + self.assertEqual((x86 / "libc.so.6").read_bytes(), b"GLIBC_2.2.5 GLIBC_2.31 GLIBC_2.34 GLIBC_2.41") + self.assertEqual((x86 / "libstdc++.so.6").read_bytes(), b"GLIBCXX_3.4.28 GLIBCXX_3.4.33") + self.assertEqual((x86 / "ld-linux-x86-64.so.2").read_bytes(), b"new ld") + self.assertEqual((x86 / "gconv/NEW.so").read_bytes(), b"new gconv") + self.assertEqual((root / "lib/i386-linux-gnu/libc.so.6").read_bytes(), b"GLIBC_2.41") + self.assertEqual((x86 / "libSDL2-2.0.so.0").read_bytes(), b"sdl") + self.assertEqual(os.readlink(x86 / "libfoo.so.1"), "libfoo.so.1.2") + self.assertEqual((x86 / "libfoo.so.1").read_bytes(), b"foo") + for stale in ("libc-2.31.so", "ld-2.31.so", "libSegFault.so"): + self.assertFalse(os.path.lexists(x86 / stale), stale) + self.assertEqual(os.readlink(root / "lib/ld-linux.so.2"), "i386-linux-gnu/ld-linux.so.2") + self.assertEqual((root / "lib/ld-linux.so.2").read_bytes(), b"new ld32") + self.assertEqual(os.readlink(root / "lib64/ld-linux-x86-64.so.2"), "../lib/x86_64-linux-gnu/ld-linux-x86-64.so.2") + self.assertEqual((root / "lib64/ld-linux-x86-64.so.2").read_bytes(), b"new ld") + self.assertEqual(os.readlink(root / "usr"), ".") + self.assertEqual((root / "usr/bin/[").read_bytes(), b"blob") + self.assertEqual((sniper / "files/lib/x86_64-linux-gnu/libc.so.6").read_bytes(), b"GLIBC_2.2.5 GLIBC_2.31") + status = json.loads((self.home / ".local/share/droiddeck-fex/status.json").read_text()) + self.assertEqual(status["graphics"], str(provider)) + self.assertNotIn("built", self.quiet(FEX["prepare"])[1]) + os.utime(provider / "usr-mtree.txt.gz", ns=(1, 1)) + self.assertIn("built the x86 runtime", self.quiet(FEX["prepare"])[1]) + self.assertEqual(sorted(os.listdir(root.parent)), ["config", "lock", "rootfs", "status.json"]) + + def test_a_runtime_that_is_not_newer_is_left_out(self): + self.fex_tool() + sniper = self.runtime() + FEX["materialize"](str(sniper)) + self.graphics(sniper, libc=b"GLIBC_2.2.5 GLIBC_2.31", libstdcxx=b"GLIBCXX_3.4.28") + fex, said = self.quiet(FEX["prepare"]) + self.assertEqual(fex["rootfs"], str(sniper / "files")) + self.assertIn("has no newer C and C++ libraries than sniper_platform_", said) + self.assertFalse((self.home / ".local/share/droiddeck-fex/rootfs").exists()) + + def test_x86_steamworks_finds_the_x86_client(self): + for directory, machine in (("linux64", 62), ("linux32", 3), ("linuxarm64", 183)): + self.write(self.steam / directory / "steamclient.so", elf(machine)) + sdk = self.home / ".steam" + sdk.mkdir(parents=True) + os.symlink(self.steam / "linuxarm64", sdk / "sdk64") + os.symlink(self.steam / "linuxarm64", sdk / "sdkarm64") + FEX["link_steam_sdk"]() + self.assertEqual(os.readlink(sdk / "sdk64"), str(self.steam / "linux64")) + self.assertEqual(os.readlink(sdk / "sdk32"), str(self.steam / "linux32")) + self.assertEqual(os.readlink(sdk / "sdkarm64"), str(self.steam / "linuxarm64")) + os.unlink(sdk / "sdk32") + os.symlink(self.tmp / "elsewhere", sdk / "sdk32") + self.write(self.tmp / "elsewhere/steamclient.so", elf(3)) + FEX["link_steam_sdk"]() + self.assertEqual(os.readlink(sdk / "sdk32"), str(self.tmp / "elsewhere")) + self.assertEqual(sorted(os.listdir(sdk)), ["sdk32", "sdk64", "sdkarm64"]) def test_status_refreshes_what_the_app_reads_once_steam_installed_fex(self): with mock.patch.dict(G, {"steam_running": lambda: False}): @@ -397,7 +492,7 @@ class LaunchTest(FexTestCase): arm = self.write(self.tmp / "arm.so", elf(183)) x86 = self.write(self.tmp / "x86.so", elf(62)) env = {"LD_PRELOAD": "%s:%s %s" % (arm, x86, self.tmp / "gone.so"), - "LD_LIBRARY_PATH": "/steam/steamrtarm64:/usr/lib/aarch64-linux-gnu:/game/lib", + "LD_LIBRARY_PATH": "/steam/steamrtarm64:/usr/lib/aarch64-linux-gnu:/game/lib:/steam/linuxarm64", "FEX_ROOTFS": "/usr/share/guestos/fex-mesa", "FEX_TSOENABLED": "1"} guest = FEX["guest_environment"]({"portable": True}, env) self.assertEqual((guest["LD_PRELOAD"], guest["LD_LIBRARY_PATH"], guest["FEX_PORTABLE"]), (str(x86), "/game/lib", "1")) @@ -405,8 +500,10 @@ class LaunchTest(FexTestCase): self.assertEqual(guest["FEX_TSOENABLED"], "1") self.assertTrue(guest["FEX_APP_CONFIG_LOCATION"].endswith("/config/")) self.assertEqual((guest["FEX_SILENTLOG"], guest["FEX_OUTPUTLOG"]), ("0", "stderr")) - guest = FEX["guest_environment"]({"portable": False}, {"LD_PRELOAD": str(arm), "FEX_PORTABLE": "1", "FEX_OUTPUTLOG": "/tmp/fex.log"}) - self.assertEqual(guest["FEX_OUTPUTLOG"], "/tmp/fex.log") + self.assertEqual(guest["GC_DISABLE_INCREMENTAL"], "1") + guest = FEX["guest_environment"]({"portable": False}, {"LD_PRELOAD": str(arm), "FEX_PORTABLE": "1", "FEX_OUTPUTLOG": "/tmp/fex.log", + "GC_DISABLE_INCREMENTAL": "0"}) + self.assertEqual((guest["FEX_OUTPUTLOG"], guest["GC_DISABLE_INCREMENTAL"]), ("/tmp/fex.log", "0")) self.assertNotIn("LD_PRELOAD", guest) self.assertNotIn("FEX_PORTABLE", guest) @@ -470,9 +567,8 @@ class LaunchTest(FexTestCase): self.runtime() self.write(tool / "usr/share/fex-emu/GuestThunks/libvulkan-guest.so", elf(62) + b"\0libstdc++.so.6\0GLIBCXX_3.4\0GLIBCXX_3.4.29\0libc.so.6\0GLIBC_2.2.5\0GLIBC_2.34\0") - self.write(self.tmp / "preloads/x86_64/libthunkaudit.so", elf(62)) - pin = self.write(self.tmp / "preloads/x86_64/libvulkan-thunk.so", elf(62)) - audit = "/usr/lib/x86_64-linux-gnu/droiddeck/libthunkaudit.so" + pin = self.write(self.tmp / "preloads/x86_64/libvulkan-thunk.so", elf(62) + b"\0libc.so.6\0GLIBC_2.2.5\0") + pinned = "/usr/lib/x86_64-linux-gnu/droiddeck/vulkan" def app(name, glibc=b"GLIBC_2.34\0GLIBC_2.44", glibcxx=b"GLIBCXX_3.4.29\0GLIBCXX_3.4.36", bundled=True): run = self.write(self.tmp / name / "AppRun", elf(62), 0o755) @@ -487,25 +583,32 @@ class LaunchTest(FexTestCase): _, said = self.quiet(FEX["launch"], [str(program)], dict(env or {}), "on", decide) return emulated.call_args[0][2], said + def search(env): + return env.get("LD_LIBRARY_PATH", "").split(":")[0] + env, said = started(app("new")) - self.assertEqual(env["LD_AUDIT"], audit) - self.assertIn("thunks on, its own Vulkan loader too", said) - env, _ = started(app("kept"), {"LD_AUDIT": "/opt/audit.so"}) - self.assertEqual(env["LD_AUDIT"], audit + ":/opt/audit.so") - env, _ = started(self.write(self.tmp / "elsewhere/run.sh", "#!/bin/sh\n", 0o755), decide=str(self.tmp / "new")) - self.assertEqual(env["LD_AUDIT"], audit) - env, said = started(app("old-cxx", glibcxx=b"GLIBCXX_3.4.28")) + self.assertEqual(search(env), pinned) self.assertNotIn("LD_AUDIT", env) + self.assertNotIn("LD_BIND_NOW", env) + self.assertIn("thunks on, its own Vulkan loader too", said) + env, _ = started(app("kept"), {"LD_LIBRARY_PATH": "/game/lib"}) + self.assertEqual(env["LD_LIBRARY_PATH"], pinned + ":/game/lib") + env, _ = started(self.write(self.tmp / "elsewhere/run.sh", "#!/bin/sh\n", 0o755), decide=str(self.tmp / "new")) + self.assertEqual(search(env), pinned) + env, said = started(app("old-cxx", glibcxx=b"GLIBCXX_3.4.28")) + self.assertNotEqual(search(env), pinned) self.assertIn("thunks on)", said) - self.assertNotIn("LD_AUDIT", started(app("old-libc", glibc=b"GLIBC_2.31"))[0]) - self.assertNotIn("LD_AUDIT", started(app("runtime-only", bundled=False))[0]) + self.assertNotEqual(search(started(app("old-libc", glibc=b"GLIBC_2.31"))[0]), pinned) + self.assertNotEqual(search(started(app("runtime-only", bundled=False))[0]), pinned) i386 = self.write(self.tmp / "new/game.i386", elf(3), 0o755) - self.assertNotIn("LD_AUDIT", started(i386)[0]) + self.assertNotEqual(search(started(i386)[0]), pinned) + self.write(pin, elf(62) + b"\0libc.so.6\0GLIBC_2.45\0") + self.assertNotEqual(search(started(app("new"))[0]), pinned) pin.unlink() - self.assertNotIn("LD_AUDIT", started(app("no-pin"))[0]) + self.assertNotEqual(search(started(app("no-pin"))[0]), pinned) self.write(pin, elf(62)) (tool / "usr/share/fex-emu/GuestThunks/libvulkan-guest.so").unlink() - self.assertNotIn("LD_AUDIT", started(app("no-thunk"))[0]) + self.assertNotEqual(search(started(app("no-thunk"))[0]), pinned) def test_steam_verbs(self): with mock.patch.dict(G, {"launch": mock.Mock(return_value=0)}): @@ -573,6 +676,53 @@ class AppInfoTest(unittest.TestCase): "40": None, "41": None, "50": "droiddeck-fex", }, hex(magic)) + @staticmethod + def manifest(steam, app, flags="4"): + (steam / "steamapps").mkdir(exist_ok=True) + (steam / ("steamapps/appmanifest_%s.acf" % app)).write_text('"AppState" { "appid" "%s" "StateFlags" "%s" }' % (app, flags)) + + def test_dual_platform_titles_keep_the_proton_default_unless_the_user_picks_fex(self): + with tempfile.TemporaryDirectory() as tmp: + steam = Path(tmp) / "Steam" + (steam / "appcache").mkdir(parents=True) + (steam / "appcache/appinfo.vdf").write_bytes(self.appinfo({ + 10: ("windows,linux", "", "game"), 20: ("windows,linux", "", "game"), 30: ("windows,linux", "", "game"), + 40: ("linux", "", "game"), + })) + self.COMPAT["APPINFO_CACHE"].clear() + for app in ("3127680", "1628350", "4183110"): + self.manifest(steam, app) + tool, fex = self.COMPAT["TOOL"], self.COMPAT["FEX_TOOL"] + state = Path(tmp) / "state" + state.mkdir() + (state / "state.json").write_text(json.dumps({"auto": {"20": fex}})) + catalog = [{"name": tool, "display": "Proton Experimental ARM64", "dir": "Proton Experimental (ARM64)", "valve": True}] + config = steam / "config/config.vdf" + config.parent.mkdir() + config.write_text('"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { ' + '"0" { "name" "%s" "config" "" "priority" "75" } ' + '"20" { "name" "%s" "config" "" "priority" "250" } ' + '"30" { "name" "%s" "config" "" "priority" "250" } } } } } }' % (tool, fex, fex)) + self.COMPAT["register_default"](str(config), ["10", "20", "30", "40"], catalog=catalog, directory=str(state)) + self.assertEqual(self.COMPAT["read_config_mapping"](str(config)), + {"0": tool, "10": tool, "20": tool, "30": fex, "40": fex}) + + def test_tools_are_fetched_until_fully_installed_and_up_to_date(self): + with tempfile.TemporaryDirectory() as tmp: + steam = Path(tmp) + (steam / "steamapps").mkdir() + downloads = self.COMPAT["tool_downloads"] + self.assertEqual(downloads(str(steam)), [ + ("4427310", "install"), ("3127680", "install"), ("1628350", "install"), ("4183110", "install")]) + self.manifest(steam, "4427310", flags="6") + self.manifest(steam, "3127680", flags="1026") + self.manifest(steam, "1628350", flags="4") + (steam / "steamapps/appmanifest_4183110.acf").write_text('"AppState" { "appid" "4183110" }') + self.assertEqual(downloads(str(steam)), [("4427310", "update"), ("3127680", "update"), ("4183110", "update")]) + for app in ("4427310", "3127680", "4183110"): + self.manifest(steam, app) + self.assertEqual(downloads(str(steam)), []) + def test_titles_with_a_valve_pick_are_found_installed_or_not(self): def deck(oslist, runtime, kind="game"): common = {"type": kind, "oslist": oslist, "steam_deck_compatibility": {"configuration": {"recommended_runtime": runtime}}} diff --git a/tools/tests/test_fakeinput_ring.py b/tools/tests/test_fakeinput_ring.py new file mode 100644 index 000000000..3c87bd11e --- /dev/null +++ b/tools/tests/test_fakeinput_ring.py @@ -0,0 +1,144 @@ +import os +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import tempfile +import unittest + +SOURCE = Path(__file__).resolve().parents[2] / "app/src/main/cpp/fakeinput_steam.cpp" +RING_MAGIC = 0x46494252 +DESCRIPTION_MAGIC = 0x46495544 +HEADER = 64 +EVENT = 24 +CAPACITY = 4096 +DESCRIPTION = 1824 +READER = r""" +#include +#include +#include +#include + +int main(void) +{ + struct input_event events[64]; + int fd = open("/dev/input/event16", O_RDONLY | O_NONBLOCK); + if (fd < 0) { + perror("open"); + return 1; + } + while (read(fd, events, sizeof(events)) > 0) + ; + puts("ready"); + fflush(stdout); + char go; + if (read(0, &go, 1) != 1) + return 1; + ssize_t got = read(fd, events, sizeof(events)); + for (ssize_t i = 0; i < got / (ssize_t)sizeof(events[0]); i++) + printf("%ld.%06ld %u %u %d\n", (long)events[i].time.tv_sec, (long)events[i].time.tv_usec, events[i].type, + events[i].code, events[i].value); + return got > 0 ? 0 : 1; +} +""" +PROBE = r""" +#include +#include +#include +#include +#include +#include + +int main(void) +{ + char name[128] = ""; + struct input_id id = {0}; + int fd = open("/dev/input/event16", O_RDONLY | O_NONBLOCK); + if (fd < 0) + return 1; + int joydev = ioctl(fd, JSIOCGNAME(sizeof(name)), name); + printf("JSIOCGNAME %d %s\n", joydev, joydev < 0 && errno == EINVAL ? "EINVAL" : name); + if (ioctl(fd, EVIOCGID, &id) < 0 || ioctl(fd, EVIOCGNAME(sizeof(name)), name) < 0) + return 1; + printf("%04x:%04x %s\n", id.vendor, id.product, name); + return 0; +} +""" + + +def can_build(bits): + with tempfile.TemporaryDirectory() as tmp: + probe = Path(tmp) / "probe.cpp" + probe.write_text("#include \nint main() { return std::string().size(); }\n") + return subprocess.run(["g++", bits, "-static-libstdc++", "-o", str(Path(tmp) / "probe"), str(probe)], + capture_output=True).returncode == 0 + + +@unittest.skipUnless(sys.platform == "linux" and os.uname().machine == "x86_64" and shutil.which("g++") + and shutil.which("gcc"), "needs gcc and g++ on an x86-64 Linux host") +class FakeinputRingTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + + def ring(self): + hooks = self.tmp / "hooks" + hooks.mkdir() + (hooks / "event16").touch() + ring = bytearray(HEADER + CAPACITY * EVENT + DESCRIPTION) + struct.pack_into(" eval(script), /install calls are not ready/); +const opened = [], queued = []; +let wizard = null, continued = 0; +globalThis.SteamClient = { + Installs: { + OpenInstallWizard(apps, client) { opened.push([apps, client]); }, + RegisterForShowInstallWizard(callback) { assert.strictEqual(wizard, null); wizard = callback; return { unregister() {} }; }, + ContinueInstall() { continued++; }, + }, + Downloads: { QueueAppUpdate(app, client) { queued.push([app, client]); } }, +}; +assert.strictEqual(eval(script), 'installed'); +assert.strictEqual(eval(script), 'present'); +const tools = window.__droiddeckTools; +wizard({ eInstallState: 7, currentAppID: 4183110 }); +assert.strictEqual(continued, 0); +tools.install(4183110); +assert.deepStrictEqual(opened, [[[4183110], '0']]); +wizard({ eInstallState: 8, currentAppID: 4183110 }); +wizard({ eInstallState: 7, currentAppID: 892970 }); +assert.strictEqual(continued, 0); +wizard({ eInstallState: 7, currentAppID: 4183110 }); +wizard({ eInstallState: 7, currentAppID: 4183110 }); +assert.strictEqual(continued, 1); +tools.update(4427310); +assert.deepStrictEqual(queued, [[4427310, '0']]); +""" + with tempfile.TemporaryDirectory() as tmp: + script = Path(tmp) / "tools.js" + script.write_text(LIVE["TOOLS_JS"]) + harness = Path(tmp) / "harness.js" + harness.write_text(harness_source) + result = subprocess.run(["node", str(harness), str(script)], capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stderr) + def test_steamvr_library_launch_uses_binding_and_other_games_pass_through(self): harness_source = r""" const assert = require('assert'); @@ -334,19 +375,19 @@ class ReconcileTest(unittest.TestCase): state = self.reconcile(page) self.assertEqual(page.sets, []) self.assertEqual(state["auto"], {}) - self.assertEqual(state["native"], [app]) + self.assertEqual(state["native_choices"], [app]) self.assertEqual(COMPAT["read_config_mapping"](str(config)), {"0": TOOL}) page.sets.clear() state = self.reconcile(page) self.assertEqual(page.sets, []) - self.assertEqual(state["native"], [app]) + self.assertEqual(state["native_choices"], [app]) restarted = LIVE["Helper"](str(self.steam), 1) restarted.page = page restarted.reconcile() state = json.loads((self.state / "state.json").read_text()) self.assertEqual(page.sets, []) - self.assertEqual(state["native"], [app]) + self.assertEqual(state["native_choices"], [app]) config.write_text( '"InstallConfigStore" { "Software" { "Valve" { "Steam" { ' @@ -356,13 +397,13 @@ class ReconcileTest(unittest.TestCase): state = self.reconcile(page) self.assertEqual(page.mapping[app], "GE-Proton11-7") self.assertEqual(page.sets, []) - self.assertEqual(state["native"], []) + self.assertEqual(state["native_choices"], []) for choice in ("steamlinuxruntime", COMPAT["FEX_TOOL"], TOOL_11): (self.state / "state.json").write_text(json.dumps({ "default": TOOL, "auto": {}, - "native": [app], + "native_choices": [app], })) config.write_text( '"InstallConfigStore" { "Software" { "Valve" { "Steam" { ' @@ -373,7 +414,7 @@ class ReconcileTest(unittest.TestCase): state = self.reconcile(page) self.assertEqual(page.mapping[app], choice) self.assertEqual(page.sets, []) - self.assertEqual(state["native"], [], choice) + self.assertEqual(state["native_choices"], [], choice) finally: COMPAT["native_targets"] = original_native_targets @@ -387,7 +428,7 @@ class ReconcileTest(unittest.TestCase): (self.state / "state.json").write_text(json.dumps({ "default": TOOL, "auto": {}, - "native": [app], + "native_choices": [app], })) original_native_targets = COMPAT["native_targets"] @@ -396,14 +437,14 @@ class ReconcileTest(unittest.TestCase): page = FakePage({"0": TOOL, app: "proton"}, [TOOL, TOOL_11]) state = self.reconcile(page) self.assertNotIn(app, self.helper.installed) - self.assertEqual(state["native"], [app]) + self.assertEqual(state["native_choices"], [app]) self.assertNotIn(app, state["auto"]) self.assertNotIn((app, TOOL), page.sets) self.assertEqual(COMPAT["read_config_mapping"](str(config)), {"0": TOOL}) config.write_text('"InstallConfigStore" { "Software" { "Other" {} } }') state = self.reconcile(page) - self.assertEqual(state["native"], [app]) + self.assertEqual(state["native_choices"], [app]) self.assertNotIn((app, TOOL), page.sets) config.write_text( @@ -412,12 +453,72 @@ class ReconcileTest(unittest.TestCase): '"%s" { "name" "GE-Proton11-7" } } } } } }' % (TOOL, app)) page.mapping[app] = "proton" state = self.reconcile(page) - self.assertEqual(state["native"], []) + self.assertEqual(state["native_choices"], []) self.assertNotIn((app, TOOL), page.sets) self.assertEqual(COMPAT["read_config_mapping"](str(config))[app], "GE-Proton11-7") finally: COMPAT["native_targets"] = original_native_targets + def dual_platform_game(self, state): + app = "892970" + (self.steam / ("steamapps/appmanifest_%s.acf" % app)).write_text("") + config = self.steam / "config/config.vdf" + config.parent.mkdir(parents=True) + config.write_text( + '"InstallConfigStore" { "Software" { "Valve" { "Steam" { ' + '"CompatToolMapping" { "0" { "name" "%s" } } } } } }' % TOOL) + (self.state / "state.json").write_text(json.dumps(state)) + patcher = mock.patch.dict(COMPAT, {"native_targets": lambda _root, apps: { + app: COMPAT["NATIVE_CHOICE"] + } if app in apps else {}}) + patcher.start() + self.addCleanup(patcher.stop) + return app, FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL, TOOL_11]) + + def test_unmapped_dual_platform_game_gets_the_default_live(self): + app, page = self.dual_platform_game({"default": TOOL, "auto": {"10": TOOL, "11": TOOL}}) + state = self.reconcile(page) + self.assertEqual(page.sets, [(app, TOOL)]) + self.assertEqual(state["auto"][app], TOOL) + self.assertEqual(state["native_choices"], []) + + page.mapping[app] = "" + page.sets.clear() + state = self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual(state["native_choices"], [app]) + + def test_unmapped_dual_platform_game_gets_the_default_without_a_readable_config(self): + app, page = self.dual_platform_game({"default": TOOL, "auto": {"10": TOOL}, "native_choices": ["11"]}) + (self.steam / "config/config.vdf").unlink() + page.mapping["11"] = "" + state = self.reconcile(page) + self.assertEqual(page.sets, [(app, TOOL)]) + self.assertEqual(state["auto"][app], TOOL) + self.assertEqual(state["native_choices"], ["11"]) + + def test_a_tool_set_live_is_kept_before_the_client_writes_its_config(self): + app, page = self.dual_platform_game({"default": TOOL, "auto": {"10": TOOL, "11": TOOL, "892970": TOOL}}) + page.known.append(COMPAT["FEX_TOOL"]) + page.mapping[app] = TOOL + config = self.steam / "config/config.vdf" + config.write_text( + '"InstallConfigStore" { "Software" { "Valve" { "Steam" { ' + '"CompatToolMapping" { "0" { "name" "%s" } "%s" { "name" "%s" } } } } } }' % (TOOL, app, TOOL)) + with mock.patch.dict(COMPAT, {"native_targets": lambda _root, apps: {app: COMPAT["FEX_TOOL"]} if app in apps else {}}): + state = self.reconcile(page) + self.assertEqual(page.sets, [(app, COMPAT["FEX_TOOL"])]) + state = self.reconcile(page) + self.assertEqual(page.sets, [(app, COMPAT["FEX_TOOL"])]) + self.assertEqual(state["auto"][app], COMPAT["FEX_TOOL"]) + + def test_native_list_from_earlier_builds_is_not_trusted(self): + app, page = self.dual_platform_game({"default": TOOL, "auto": {"10": TOOL, "11": TOOL}, "native": ["892970"]}) + state = self.reconcile(page) + self.assertEqual(page.sets, [(app, TOOL)]) + self.assertNotIn("native", state) + self.assertEqual(state["native_choices"], []) + def test_factorio_without_prior_auto_state_uses_the_default_proton(self): app = "427520" (self.steam / ("steamapps/appmanifest_%s.acf" % app)).write_text("") @@ -436,7 +537,7 @@ class ReconcileTest(unittest.TestCase): state = self.reconcile(page) self.assertEqual(page.sets, [(app, TOOL)]) self.assertEqual(state["auto"], {app: TOOL}) - self.assertEqual(state["native"], []) + self.assertEqual(state["native_choices"], []) self.assertEqual(COMPAT["read_config_mapping"](str(config)), {"0": TOOL}) finally: COMPAT["native_targets"] = original_native_targets @@ -485,6 +586,79 @@ console.log(JSON.stringify({ sent: sent.length, asked: asked.length })); """ +class ToolPage(FakePage): + def __init__(self, fail=False): + super().__init__({"0": TOOL}, [TOOL]) + self.calls = [] + self.fail = fail + + def tools(self, call): + self.calls.append(call) + if self.fail: + raise RuntimeError("the client's install calls are not ready") + return True + + +class ToolDownloadTest(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.steam = Path(tmp.name) / "Steam" + (self.steam / "steamapps").mkdir(parents=True) + self.helper = LIVE["Helper"](str(self.steam), 1) + self.now = [1000.0] + self.running = [False] + self.requested = [] + patcher = mock.patch.dict(COMPAT, { + "game_running": lambda: self.running[0], + "request_fex": lambda root, asked: self.requested.append(root) or ["3127680"], + }) + patcher.start() + self.addCleanup(patcher.stop) + clock = mock.patch.object(LIVE["time"], "monotonic", lambda: self.now[0]) + clock.start() + self.addCleanup(clock.stop) + + def manifest(self, app, flags): + (self.steam / ("steamapps/appmanifest_%s.acf" % app)).write_text('"AppState" { "StateFlags" "%s" }' % flags) + + def test_missing_tools_are_installed_and_deferred_ones_queued(self): + self.manifest("4427310", "6") + self.manifest("3127680", "4") + self.manifest("1628350", "1026") + self.helper.page = page = ToolPage() + self.helper.ensure_tools() + self.assertEqual(page.calls, ["update(4427310)", "update(1628350)", "install(4183110)"]) + self.now[0] += 30 + self.helper.ensure_tools() + self.now[0] += 60 + self.helper.ensure_tools() + self.assertEqual(len(page.calls), 3) + self.now[0] += 300 + self.manifest("4427310", "4") + self.helper.ensure_tools() + self.assertEqual(page.calls[3:], ["update(1628350)", "install(4183110)"]) + self.assertEqual(self.requested, []) + + def test_nothing_is_queued_while_a_game_runs(self): + self.running[0] = True + self.helper.page = page = ToolPage() + self.helper.ensure_tools() + self.assertEqual(page.calls, []) + self.running[0] = False + self.now[0] += 60 + self.helper.ensure_tools() + self.assertEqual(len(page.calls), 4) + + def test_an_install_falls_back_to_the_steam_url(self): + for app in ("4427310", "1628350", "4183110"): + self.manifest(app, "4") + self.helper.page = page = ToolPage(fail=True) + self.helper.ensure_tools() + self.assertEqual(page.calls, ["install(3127680)"]) + self.assertEqual(self.requested, [str(self.steam)]) + + @unittest.skipUnless(shutil.which("node"), "node is not installed") class SwitchScriptTest(unittest.TestCase): def test_the_request_is_reported_and_still_sent(self): diff --git a/tools/tests/test_steam_libraries.py b/tools/tests/test_steam_libraries.py index 3987470ba..4f416a840 100644 --- a/tools/tests/test_steam_libraries.py +++ b/tools/tests/test_steam_libraries.py @@ -121,7 +121,7 @@ class CompatLibrariesTest(unittest.TestCase): 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))) + self.assertEqual(["1628350", "4183110"], COMPAT["fex_missing"](str(self.steam))) def test_newest_proton_across_libraries_wins(self): for root, build in ((self.steam, "100"), (self.pc, "200")): diff --git a/tools/tests/test_vulkan_pin.py b/tools/tests/test_vulkan_pin.py new file mode 100644 index 000000000..c848f77f7 --- /dev/null +++ b/tools/tests/test_vulkan_pin.py @@ -0,0 +1,141 @@ +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +SOURCE = Path(__file__).resolve().parents[1] / "linuxfs/fex/vulkanpin.c" +THUNK = r""" +#include +typedef struct { char extensionName[256]; unsigned int specVersion; } VkExtensionProperties; +typedef void (*Function)(void); +static const char *names[] = {"VK_KHR_surface", "VK_KHR_display", "VK_KHR_xlib_surface", "VK_EXT_direct_mode_display", + "VK_KHR_get_display_properties2", "VK_EXT_swapchain_colorspace"}; + +int vkEnumerateInstanceExtensionProperties(const char *layer, unsigned int *count, VkExtensionProperties *properties) +{ + unsigned int total = layer ? 0 : sizeof(names) / sizeof(names[0]); + if (!properties) { + *count = total; + return 0; + } + unsigned int n = *count < total ? *count : total; + for (unsigned int i = 0; i < n; i++) { + memset(&properties[i], 0, sizeof(properties[i])); + strcpy(properties[i].extensionName, names[i]); + properties[i].specVersion = i + 1; + } + *count = n; + return n < total ? 5 : 0; +} + +int vkCreateInstance(void) { return 42; } + +Function vkGetInstanceProcAddr(void *instance, const char *name) +{ + (void)instance; + if (!strcmp(name, "vkEnumerateInstanceExtensionProperties")) + return (Function)vkEnumerateInstanceExtensionProperties; + if (!strcmp(name, "vkCreateInstance")) + return (Function)vkCreateInstance; + if (!strcmp(name, "vkGetInstanceProcAddr")) + return (Function)vkGetInstanceProcAddr; + return 0; +} +""" +GAME = r""" +#include +#include +typedef struct { char extensionName[256]; unsigned int specVersion; } VkExtensionProperties; +typedef void (*Function)(void); +typedef int (*Enumerate)(const char *, unsigned int *, VkExtensionProperties *); +typedef Function (*ProcAddr)(void *, const char *); +#ifdef LINKED +int vkCreateInstance(void); +Function vkGetInstanceProcAddr(void *, const char *); +#endif + +static void list(Enumerate enumerate) +{ + VkExtensionProperties found[8]; + unsigned int count = 0; + int result = enumerate(0, &count, 0); + printf("count %u %d\n", count, result); + enumerate(0, &count, found); + for (unsigned int i = 0; i < count; i++) + printf("%s %u\n", found[i].extensionName, found[i].specVersion); + count = 1; + result = enumerate(0, &count, found); + printf("short %u %d %s\n", count, result, found[0].extensionName); +} + +int main(void) +{ +#ifdef LINKED + ProcAddr proc = vkGetInstanceProcAddr; + printf("linked %d\n", vkCreateInstance()); +#else + void *vulkan = dlopen("libvulkan.so.1", RTLD_NOW | RTLD_LOCAL); + if (!vulkan) + return 1; + ProcAddr proc = (ProcAddr)dlsym(vulkan, "vkGetInstanceProcAddr"); + printf("same handle %d\n", dlopen("libvulkan.so", RTLD_NOW | RTLD_NOLOAD) == vulkan); + printf("create %d\n", ((int (*)(void))dlsym(vulkan, "vkCreateInstance"))()); + list((Enumerate)dlsym(vulkan, "vkEnumerateInstanceExtensionProperties")); +#endif + printf("same proc %d create %d missing %d\n", proc(0, "vkGetInstanceProcAddr") == (Function)proc, + ((int (*)(void))proc(0, "vkCreateInstance"))(), proc(0, "vkNothing") == 0); + list((Enumerate)proc(0, "vkEnumerateInstanceExtensionProperties")); + return 0; +} +""" +LISTED = """count 3 0 +VK_KHR_surface 1 +VK_KHR_xlib_surface 3 +VK_EXT_swapchain_colorspace 6 +short 1 5 VK_KHR_surface +""" + + +@unittest.skipUnless(shutil.which("gcc") and os.uname().machine == "x86_64", "needs gcc on an x86-64 host") +class VulkanPinTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + + def gcc(self, *args): + made = subprocess.run(["gcc"] + [str(a) for a in args], capture_output=True, text=True) + self.assertEqual(made.returncode, 0, made.stderr) + + def test_display_extensions_are_hidden_in_front_of_the_thunk(self): + thunk = self.tmp / "usr/lib/libvulkan.so.1" + thunk.parent.mkdir(parents=True) + (self.tmp / "thunk.c").write_text(THUNK) + self.gcc("-shared", "-fPIC", "-Wl,-soname,libvulkan.so.1", "-o", thunk, self.tmp / "thunk.c") + stub = self.tmp / "stub/libvulkan.so.1" + stub.parent.mkdir() + self.gcc("-shared", "-nostdlib", "-Wl,-soname," + str(thunk), "-o", stub, "-x", "c", "/dev/null") + pinned = self.tmp / "pin" + pinned.mkdir() + self.gcc("-shared", "-fPIC", "-O2", "-Wall", "-Wextra", "-Werror", "-fvisibility=hidden", '-DVULKAN_THUNK="%s"' % thunk, + "-Wl,-soname,libvulkan.so.1", "-Wl,-z,nodelete", "-Wl,--no-as-needed", "-o", pinned / "libvulkan.so.1", + SOURCE, stub, "-ldl") + os.symlink("libvulkan.so.1", pinned / "libvulkan.so") + exported = subprocess.run(["nm", "-D", "--defined-only", pinned / "libvulkan.so.1"], capture_output=True, text=True) + self.assertEqual(sorted(line.split()[-1] for line in exported.stdout.splitlines()), + ["vkEnumerateInstanceExtensionProperties", "vkGetInstanceProcAddr"]) + (self.tmp / "game.c").write_text(GAME) + env = dict(os.environ, LD_LIBRARY_PATH=str(pinned)) + self.gcc("-o", self.tmp / "loader", self.tmp / "game.c", "-ldl") + run = subprocess.run([self.tmp / "loader"], capture_output=True, text=True, env=env) + self.assertEqual(run.returncode, 0, run.stderr) + self.assertEqual(run.stdout, "same handle 1\ncreate 42\n" + LISTED + "same proc 1 create 42 missing 1\n" + LISTED) + self.gcc("-DLINKED", "-o", self.tmp / "linked", self.tmp / "game.c", "-L", thunk.parent, "-l:libvulkan.so.1", "-ldl") + run = subprocess.run([self.tmp / "linked"], capture_output=True, text=True, env=env) + self.assertEqual(run.returncode, 0, run.stderr) + self.assertEqual(run.stdout, "linked 42\nsame proc 1 create 42 missing 1\n" + LISTED) + + +if __name__ == "__main__": + unittest.main()