mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
fix: run x86 Proton under FEX in the Steam runtime it requires (#1034)
This commit is contained in:
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 []
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user