Compare commits

...
7 Commits
Author SHA1 Message Date
MaxsTechReview 5924262bc9 fix: unmapped dual-platform games get Proton even when config.vdf cannot be read 2026-10-18 01:47:32 -04:00
MaxsTechReview 80202298ce 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.
2026-10-17 23:07:02 -04:00
MaxsTechReview f13007fced test: skip the switch script test without node, and the ring tests without gcc 2026-10-17 22:36:46 -04:00
MaxsTechReview 8f33ca31b4 Merge branch 'main' into feat/linux-games 2026-10-17 22:24:04 -04:00
MaxsTechReview 97bae3ca3c 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.
2026-10-17 22:21:14 -04:00
MaxsTechReview afbb72197b 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.
2026-10-17 21:57:44 -04:00
MaxsTechReview 8b02fcd47d 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.
2026-10-17 15:15:33 -04:00
17 changed files with 1034 additions and 142 deletions

No files matched your search

+34 -16
View File
@@ -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<struct input_event *>(reinterpret_cast<uint8_t *>(ring) +
FAKE_INPUT_RING_HEADER_SIZE);
auto *events = reinterpret_cast<RingEvent *>(reinterpret_cast<uint8_t *>(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<uint8_t *>(buf) + i * FAKE_INPUT_EVENT_SIZE, &ev,
FAKE_INPUT_EVENT_SIZE);
memcpy(static_cast<uint8_t *>(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<ssize_t>(events * FAKE_INPUT_EVENT_SIZE);
return static_cast<ssize_t>(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<uint8_t *>(buf) + i * FAKE_INPUT_EVENT_SIZE,
&ev, FAKE_INPUT_EVENT_SIZE);
memcpy(static_cast<uint8_t *>(buf) + i * sizeof(ev), &ev, sizeof(ev));
--fake.keyframe_remaining;
}
return static_cast<ssize_t>(events * FAKE_INPUT_EVENT_SIZE);
return static_cast<ssize_t>(events * sizeof(struct input_event));
}
if (nonblock) {
errno = EAGAIN;
@@ -123,7 +123,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",
+9 -6
View File
@@ -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
-40
View File
@@ -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;
}
+88
View File
@@ -0,0 +1,88 @@
#include <dlfcn.h>
#include <stddef.h>
#include <string.h>
#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;
}
+124 -20
View File
@@ -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)
@@ -424,6 +524,8 @@ def guest_environment(fex, env):
result["FEX_APP_CACHE_LOCATION"] = CACHE_DIR + "/"
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:
@@ -439,7 +541,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)
@@ -477,6 +579,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)
@@ -503,9 +606,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)
@@ -557,7 +661,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")
@@ -673,7 +777,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
@@ -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():
@@ -93,7 +93,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"
@@ -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")
@@ -820,6 +824,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:
@@ -1144,6 +1178,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:
@@ -1178,18 +1227,20 @@ 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)
managed = state.get("auto", {}).keys() if isinstance(state.get("auto"), dict) else ()
native_choices = state.get("native_choices", ())
changes, auto = plan_mapping(
current, apps, names, default, followers(state, current),
native_targets(steam_root, set(apps) | set(picks)), picks,
overrides, state.get("auto", {}).keys() if isinstance(state.get("auto"), dict) else (),
state.get("native", ()))
native_targets(steam_root, set(apps) | set(picks) | set(managed) | set(native_choices)), 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)
+1 -1
View File
@@ -70,7 +70,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']]
+172 -22
View File
@@ -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}}}
+144
View File
@@ -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 <fcntl.h>
#include <linux/input.h>
#include <stdio.h>
#include <unistd.h>
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 <errno.h>
#include <fcntl.h>
#include <linux/input.h>
#include <linux/joystick.h>
#include <stdio.h>
#include <sys/ioctl.h>
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 <string>\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("<IIIIQQQ", ring, 0, RING_MAGIC, 2, EVENT, CAPACITY, 5, 0, 0)
struct.pack_into("<IiHHHHI80s", ring, HEADER + CAPACITY * EVENT, DESCRIPTION_MAGIC, os.getpid(),
3, 0x28de, 0x11ff, 1, 16, b"Microsoft X-Box 360 pad 0")
path = hooks / ".uinput-event16"
path.write_bytes(bytes(ring))
return hooks, path
def build(self, bits, program, name):
if not can_build(bits):
self.skipTest("no %s C++ toolchain" % bits)
library = self.tmp / ("libfakeinput%s.so" % bits)
subprocess.run(["g++", bits, "-shared", "-fPIC", "-O2", "-Wno-attributes", "-pthread", "-std=c++17",
"-static-libstdc++", "-static-libgcc", "-Wl,--exclude-libs,ALL", "-o", str(library),
str(SOURCE), "-ldl"], check=True, capture_output=True)
binary = self.tmp / (name + bits)
(self.tmp / (name + ".c")).write_text(program)
subprocess.run(["gcc", bits, "-o", str(binary), str(self.tmp / (name + ".c"))], check=True)
return library, binary
def read_through(self, bits):
library, reader = self.build(bits, READER, "reader")
hooks, path = self.ring()
env = dict(os.environ, LD_PRELOAD=str(library), FAKE_EVDEV_DIR=str(hooks))
process = subprocess.Popen([str(reader)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, env=env, text=True)
self.addCleanup(process.kill)
self.assertEqual(process.stdout.readline(), "ready\n")
with open(path, "r+b") as ring:
for i, (kind, code, value) in enumerate(((1, 304, 1), (3, 1, -32768), (0, 0, 0))):
ring.seek(HEADER + (5 + i) * EVENT)
ring.write(struct.pack("<qqHHi", 1700000000 + i, 250000, kind, code, value))
ring.seek(16)
ring.write(struct.pack("<Q", 8))
output, _ = process.communicate("g", timeout=10)
self.assertEqual(process.returncode, 0)
return output
def test_a_32_bit_guest_reads_the_64_bit_ring(self):
self.assertEqual(self.read_through("-m32"),
"1700000000.250000 1 304 1\n1700000001.250000 3 1 -32768\n1700000002.250000 0 0 0\n")
def test_a_64_bit_guest_reads_the_same_ring(self):
self.assertEqual(self.read_through("-m64"),
"1700000000.250000 1 304 1\n1700000001.250000 3 1 -32768\n1700000002.250000 0 0 0\n")
def test_an_event_node_rejects_joydev_requests_so_sdl_reads_its_id(self):
library, probe = self.build("-m64", PROBE, "probe")
hooks, _ = self.ring()
run = subprocess.run([str(probe)], capture_output=True, text=True, timeout=10,
env=dict(os.environ, LD_PRELOAD=str(library), FAKE_EVDEV_DIR=str(hooks)))
self.assertEqual(run.returncode, 0, run.stderr)
self.assertEqual(run.stdout, "JSIOCGNAME -1 EINVAL\n28de:11ff Microsoft X-Box 360 pad 0\n")
if __name__ == "__main__":
unittest.main()
+10 -10
View File
@@ -522,7 +522,7 @@ class ProtonDefaultTest(unittest.TestCase):
(root / "state.json").write_text(json.dumps({
"default": self.TOOL,
"auto": {},
"native": [app],
"native_choices": [app],
}))
globals_ = COMPAT["register_default"].__globals__
@@ -534,7 +534,7 @@ class ProtonDefaultTest(unittest.TestCase):
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL})
state = json.loads((root / "state.json").read_text())
self.assertEqual(state["auto"], {})
self.assertEqual(state["native"], [app])
self.assertEqual(state["native_choices"], [app])
finally:
globals_["native_targets"] = original_native_targets
@@ -551,7 +551,7 @@ class ProtonDefaultTest(unittest.TestCase):
(root / "state.json").write_text(json.dumps({
"default": self.TOOL,
"auto": {},
"native": [app],
"native_choices": [app],
}))
globals_ = COMPAT["register_default"].__globals__
@@ -562,12 +562,12 @@ class ProtonDefaultTest(unittest.TestCase):
try:
COMPAT["register_default"](
str(config), [app], catalog=self.CATALOG, directory=tmp)
self.assertEqual(json.loads((root / "state.json").read_text())["native"], [app])
self.assertEqual(json.loads((root / "state.json").read_text())["native_choices"], [app])
COMPAT["register_default"](
str(config), [added_shortcut], catalog=self.CATALOG, directory=tmp)
state = json.loads((root / "state.json").read_text())
self.assertEqual(state["native"], [app])
self.assertEqual(state["native_choices"], [app])
self.assertEqual(state["auto"], {added_shortcut: self.TOOL})
self.assertEqual(mapping_of(config.read_text()), {
"0": self.TOOL,
@@ -613,7 +613,7 @@ class ProtonDefaultTest(unittest.TestCase):
self.assertEqual(COMPAT["tokenize"](first.decode()),
COMPAT["tokenize"](config_text([("0", self.TOOL)])))
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["auto"], {})
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["native"], ["427520"])
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["native_choices"], ["427520"])
COMPAT["register_default"](
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
@@ -625,7 +625,7 @@ class ProtonDefaultTest(unittest.TestCase):
native_choice = config.read_bytes()
self.assertEqual(mapping_of(native_choice.decode()), {"0": self.TOOL, "427520": ""})
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["auto"], {})
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["native"], ["427520"])
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["native_choices"], ["427520"])
COMPAT["register_default"](
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
self.assertEqual(config.read_bytes(), native_choice)
@@ -635,10 +635,10 @@ class ProtonDefaultTest(unittest.TestCase):
COMPAT["register_default"](
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL, "427520": choice})
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["native"], [])
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["native_choices"], [])
state = json.loads((Path(tmp) / "state.json").read_text())
state["native"] = ["427520"]
state["native_choices"] = ["427520"]
state["auto"] = {}
(Path(tmp) / "state.json").write_text(json.dumps(state))
config.write_text(config_text([("0", self.TOOL), ("427520", "proton_experimental")]))
@@ -646,7 +646,7 @@ class ProtonDefaultTest(unittest.TestCase):
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL, "427520": self.TOOL})
saved = json.loads((Path(tmp) / "state.json").read_text())
self.assertEqual(saved["native"], [])
self.assertEqual(saved["native_choices"], [])
self.assertEqual(saved["auto"], {"427520": self.TOOL})
finally:
globals_["appinfo_platforms"] = original_platforms
+3 -1
View File
@@ -32,8 +32,10 @@ class RequestFexTest(unittest.TestCase):
"start steam://install/1628350", "end steam://install/1628350"],
self.log.read_text().splitlines())
self.install("1628350")
self.assertEqual(["4183110"], COMPAT["request_fex"](str(self.root), asked))
self.install("4183110")
self.assertEqual([], COMPAT["request_fex"](str(self.root), asked))
self.assertEqual({"3127680", "1628350"}, asked)
self.assertEqual({"3127680", "1628350", "4183110"}, asked)
def test_a_declined_install_is_not_asked_again_in_the_same_session(self):
asked = set()
+185 -11
View File
@@ -117,6 +117,47 @@ class PageScriptTest(unittest.TestCase):
capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stderr)
def test_tool_installs_skip_only_their_own_install_dialog(self):
harness_source = r"""
const assert = require('assert');
globalThis.window = globalThis;
const script = require('fs').readFileSync(process.argv[2], 'utf8');
globalThis.SteamClient = { Installs: {}, Downloads: {} };
assert.throws(() => 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):
+1 -1
View File
@@ -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")):
+141
View File
@@ -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 <string.h>
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 <dlfcn.h>
#include <stdio.h>
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()