mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-18 05:07:22 -04:00
Compare commits
7
Commits
main
...
feat/linux-games
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5924262bc9 | ||
|
|
80202298ce | ||
|
|
f13007fced | ||
|
|
8f33ca31b4 | ||
|
|
97bae3ca3c | ||
|
|
afbb72197b | ||
|
|
8b02fcd47d |
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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']]
|
||||
|
||||
@@ -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}}}
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")):
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user