From 2ea86b4abb5ba57e8d79d4cf1c93d2efde7dcb41 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:18:52 -0400 Subject: [PATCH] fix: run x86 Proton under FEX in the Steam runtime it requires (#1034) --- .../overlay/usr/local/bin/droiddeck-fex | 82 +++++++++++++------ .../overlay/usr/local/bin/steam-compatibility | 31 ++++--- tools/tests/test_droiddeck_fex.py | 53 +++++++++++- 3 files changed, 127 insertions(+), 39 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex index 0de377ef0..a8627ca3a 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex @@ -30,6 +30,8 @@ FEX_APPID = "3127680" SNIPER_APPID = "1628350" SLR4_APPID = "4183110" INSTALL_DIRS = {FEX_APPID: "FEX-Emu", SNIPER_APPID: "SteamLinuxRuntime_sniper", SLR4_APPID: "SteamLinuxRuntime_4"} +RUNTIMES = {SNIPER_APPID: ("sniper_platform_", "Steam Linux Runtime 3.0 (sniper"), + SLR4_APPID: ("steamrt4_platform_", "Steam Linux Runtime 4.0 (steamrt4")} MARKER = ".droiddeck-fex" LAYOUT = 1 ROOTFS_LAYOUT = 1 @@ -174,15 +176,15 @@ def platform_version(path): return tuple(int(part) for part in re.findall(r"\d+", os.path.basename(path))) -def runtime_platform(appid=SNIPER_APPID, prefix="sniper_platform_"): - runtime = app_dir(appid) +def runtime_platform(app=SNIPER_APPID): + runtime = app_dir(app) 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(prefix)] + platforms = [os.path.join(runtime, entry) for entry in entries if entry.startswith(RUNTIMES[app][0])] 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) @@ -245,7 +247,7 @@ def materialize(platform): def graphics_platform(): - return runtime_platform(SLR4_APPID, "steamrt4_platform_") + return runtime_platform(SLR4_APPID) def provides_newer(base, provider): @@ -366,6 +368,24 @@ def link_steam_sdk(): break +def link_guest_sdk(root): + # Proton's lsteamclient only looks in $HOME/.steam/sdk64; on ARM Steam that is the arm64 client, so + # the x86 runtime gets its own link, which FEX resolves before the host path. + for sdk, candidates in (("sdk64", ("linux64", "ubuntu12_64")), ("sdk32", ("linux32", "ubuntu12_32"))): + want = "x86_64" if sdk == "sdk64" else "i386" + directory = next((os.path.join(STEAM_ROOT, candidate) for candidate in candidates + if elf_arch(os.path.join(STEAM_ROOT, candidate, "steamclient.so")) == want), None) + if not directory: + continue + link = os.path.join(root, HOME.lstrip("/"), ".steam", sdk) + if os.path.islink(link) and os.readlink(link) == directory: + continue + os.makedirs(os.path.dirname(link), exist_ok=True) + if os.path.lexists(link): + os.unlink(link) + os.symlink(directory, link) + + def steam_running(): for pid in os.listdir("/proc"): if not pid.isdigit(): @@ -380,13 +400,15 @@ def steam_running(): return False -def request_install(): +def request_install(app=SNIPER_APPID): os.makedirs(os.path.dirname(WANTED), exist_ok=True) write_if_changed(WANTED, "1\n") if not steam_running(): return [] try: compat = runpy.run_path(os.path.join(BIN_DIR, "steam-compatibility")) + if app != SNIPER_APPID and app_dir(app) is None: + return compat["request_app"](STEAM_ROOT, app) return compat["request_fex"](STEAM_ROOT, set()) except Exception as error: say("could not ask the client to install FEX (%s)" % error) @@ -406,10 +428,10 @@ def describe(fex, platform, graphics=None): } -def prepare(): +def prepare(app=SNIPER_APPID): with locked(): fex = fex_install() - platform = runtime_platform() + platform = runtime_platform(app) status = describe(fex, platform) if not status["ready"]: missing = [] @@ -422,8 +444,8 @@ def prepare(): elif not fex["server"]: missing.append("FEXServer beside %s" % fex["bin"]) if not platform: - missing.append("Steam Linux Runtime 3.0 (sniper, Steam app %s)" % SNIPER_APPID) - requested = request_install() + missing.append("%s, Steam app %s)" % (RUNTIMES[app][1], app)) + requested = request_install(app) say("x86 Linux programs need %s; %s" % (" and ".join(missing), "the Steam client was asked to install it" if requested else "open Steam once and it is installed for you")) write_if_changed(STATUS_FILE, json.dumps(status, indent=2) + "\n") @@ -432,20 +454,24 @@ 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") + provider = None + if app == SNIPER_APPID: + 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") + with contextlib.suppress(OSError): + link_guest_sdk(root) write_config(fex, root) fex["rootfs"] = root try: @@ -530,6 +556,8 @@ def guest_environment(fex, env): result["STEAM_COMPAT_DATA_PATH"] = short result.setdefault("FEX_SILENTLOG", "0") result.setdefault("FEX_OUTPUTLOG", "stderr") + # The session's tunables are for the host libc; an x86 ld.so copying them under FEX fails in sbrk. + result.pop("GLIBC_TUNABLES", None) # 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"]: @@ -594,6 +622,12 @@ def vulkan_thunk_loads(fex, folder): return True +def required_runtime(program): + text = read_text(os.path.join(os.path.dirname(program), "toolmanifest.vdf")) or "" + match = re.search(r'"require_tool_appid"\s+"(\d+)"', text) + return match.group(1) if match and match.group(1) in RUNTIMES else SNIPER_APPID + + def resolve(program, env): if "/" in program: return os.path.abspath(program) @@ -605,7 +639,7 @@ def launch(command, env, mode, decide=None): use = mode == "on" or (mode == "auto" and needs_fex(decide or program)) if not use: return os.execvpe(command[0], command, env) - fex = prepare() + fex = prepare(required_runtime(program)) if fex is None: return 126 guest = guest_environment(fex, env) diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index fb84fd94d..242d21723 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -901,24 +901,33 @@ def fex_wanted_marker(): return os.path.join(os.environ.get("HOME", "/root"), ".config", "droiddeck", "fex-wanted") -def request_fex(steam_root, asked): +def request_app(steam_root, app): import subprocess + client = os.path.join(steam_root, "steamrtarm64", "steam") + if not os.access(client, os.X_OK): + return [] + try: + process = subprocess.Popen([client, "steam://install/%s" % app], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + except OSError: + return [] + try: + process.wait(timeout=REQUEST_HANDOFF_SECONDS) + except subprocess.TimeoutExpired: + pass + return [app] + + +def request_fex(steam_root, asked): client = os.path.join(steam_root, "steamrtarm64", "steam") missing = fex_missing(steam_root) if not os.access(client, os.X_OK) or any(app in asked for app in missing): return [] for app in missing: asked.add(app) - try: - process = subprocess.Popen([client, "steam://install/%s" % app], stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, start_new_session=True) - except OSError: - continue - try: - process.wait(timeout=REQUEST_HANDOFF_SECONDS) - except subprocess.TimeoutExpired: - pass - return [app] + requested = request_app(steam_root, app) + if requested: + return requested return [] diff --git a/tools/tests/test_droiddeck_fex.py b/tools/tests/test_droiddeck_fex.py index 0af8e7f4f..79a8a38bf 100644 --- a/tools/tests/test_droiddeck_fex.py +++ b/tools/tests/test_droiddeck_fex.py @@ -69,9 +69,10 @@ class FexTestCase(unittest.TestCase): self.write(Path(library or self.steam) / "steamapps/appmanifest_3127680.acf", '"AppState" { "installdir" "FEX-Emu" }') return tool - def runtime(self, version="3.0.20260805.254768", lines=None): - runtime = self.steam / "steamapps/common/SteamLinuxRuntime_sniper" - platform = runtime / ("sniper_platform_" + version) + def runtime(self, version="3.0.20260805.254768", lines=None, app="1628350", folder="SteamLinuxRuntime_sniper", + prefix="sniper_platform_"): + runtime = self.steam / "steamapps/common" / folder + platform = runtime / (prefix + version) files = platform / "files" self.write(files / "lib/x86_64-linux-gnu/libc.so.6", elf(62)) self.write(files / "ab/cdef-1.bin", b"blob") @@ -89,9 +90,16 @@ class FexTestCase(unittest.TestCase): ] with gzip.open(platform / "usr-mtree.txt.gz", "wt") as out: out.write("\n".join(mtree) + "\n") - self.write(self.steam / "steamapps/appmanifest_1628350.acf", '"AppState" { "installdir" "SteamLinuxRuntime_sniper" }') + self.write(self.steam / ("steamapps/appmanifest_%s.acf" % app), '"AppState" { "installdir" "%s" }' % folder) return platform + def steamrt4(self): + return self.runtime("4.0.20260928.262390", app="4183110", folder="SteamLinuxRuntime_4", prefix="steamrt4_platform_") + + def proton(self, require): + proton = self.steam / "steamapps/common/Proton - Experimental" + self.write(proton / "toolmanifest.vdf", '"manifest"\n{\n "commandline" "/proton %%verb%%"\n "require_tool_appid" "%s"\n}\n' % require) + return self.write(proton / "proton", "#!/usr/bin/env python3\n", 0o755) 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" @@ -272,6 +280,29 @@ class PrepareTest(FexTestCase): status = json.loads((self.home / ".local/share/droiddeck-fex/status.json").read_text()) self.assertFalse(status["ready"]) + def test_a_missing_steam_runtime_4_is_named(self): + self.fex_tool() + self.runtime() + with mock.patch.dict(G, {"steam_running": lambda: False}): + fex, said = self.quiet(FEX["prepare"], "4183110") + self.assertIsNone(fex) + self.assertIn("Steam Linux Runtime 4.0 (steamrt4, Steam app 4183110)", said) + self.assertNotIn("FEX (Steam app", said) + + def test_the_x86_runtime_gets_its_own_steam_client_link(self): + self.fex_tool() + platform = self.runtime() + self.write(self.steam / "linuxarm64/steamclient.so", elf(183)) + self.write(self.steam / "linux64/steamclient.so", elf(62)) + self.write(self.steam / "linux32/steamclient.so", elf(3)) + (self.home / ".steam").mkdir() + os.symlink(self.steam / "linuxarm64", self.home / ".steam/sdk64") + self.quiet(FEX["prepare"]) + guest = platform / "files" / str(self.home).lstrip("/") / ".steam" + self.assertEqual(os.readlink(guest / "sdk64"), str(self.steam / "linux64")) + self.assertEqual(os.readlink(guest / "sdk32"), str(self.steam / "linux32")) + self.assertEqual(os.readlink(self.home / ".steam/sdk64"), str(self.steam / "linux64")) + def test_a_ready_install_is_prepared(self): tool = self.fex_tool() platform = self.runtime() @@ -528,6 +559,20 @@ class LaunchTest(FexTestCase): thunks = json.loads((self.home / ".local/share/droiddeck-fex/config/ThunksConfig.json").read_text()) self.assertEqual(thunks, {"ThunksDB": {"GL": 1, "Vulkan": 1}}) + def test_host_libc_tunables_stay_on_the_host(self): + guest = FEX["guest_environment"]({"portable": True}, {"GLIBC_TUNABLES": "glibc.pthread.rseq=0"}) + self.assertNotIn("GLIBC_TUNABLES", guest) + + def test_proton_runs_in_the_runtime_its_manifest_requires(self): + self.fex_tool() + self.runtime() + platform = self.steamrt4() + proton = self.proton("4183110") + with mock.patch("os.execve") as emulated: + self.quiet(FEX["launch"], [str(proton), "waitforexitandrun", "game.exe"], {}, "on") + self.assertEqual(emulated.call_args[0][2]["FEX_ROOTFS"], str(platform / "files")) + self.assertEqual(FEX["required_runtime"](str(self.proton("1391110"))), "1628350") + def test_native_programs_run_directly(self): program = self.write(self.tmp / "native", elf(183), 0o755) with mock.patch("os.execvpe") as native, mock.patch("os.execve") as emulated: