fix: run x86 Proton under FEX in the Steam runtime it requires (#1034)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-19 14:18:52 -04:00
1 parent 7c20d4b5c3
commit 2ea86b4abb
3 files changed
+127 -39

No files matched your search

@@ -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)
@@ -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 []
+49 -4
View File
@@ -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: