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()