From f20c8d47ccd68e8be882102fb386bec0bf8878f4 Mon Sep 17 00:00:00 2001 From: Matthew Schwartz Date: Sat, 19 Sep 2026 16:11:43 -0700 Subject: [PATCH 1/6] Add support for ARM64 Proton ARM64 Proton ships its Wine executables in `files/bin-arm64` instead of `files/bin`, and the official Valve ARM64 builds ship no `files/bin` at all. Add a `SteamApp.proton_bin_path` property that prefers `bin-arm64` and falls back to `bin`, mirroring the existing `files`/`dist` fallback in `SteamApp.proton_dist_path`. The Wine proxy scripts receive the directory in a new `PROTON_BIN_PATH` environment variable. ARM64 compatibility tools are also declared in a Steam Play manifest of their own rather than the main one, so names such as `proton-experimental-arm64` can't be resolved. Search every `appinfo.vdf` section declaring compatibility tools instead of only the main manifest. The main manifest is searched first so that existing installations keep resolving as before, and a tool's own name now takes precedence over the same name used as an alias by a tool in an earlier manifest, as `proton-experimental` is an alias in both manifests. --- CHANGELOG.md | 1 + src/protontricks/data/scripts/wine_launch.sh | 2 +- src/protontricks/steam.py | 102 +++++++++++++++--- src/protontricks/util.py | 12 ++- tests/cli/test_main.py | 6 +- tests/test_steam.py | 104 ++++++++++++++++++- 6 files changed, 203 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 266f170..1cfc9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added + - Add support for ARM64 Proton installations - Add desktop file associations for batch files and Internet shortcuts - Add support for passing Winetricks arguments after `--` when using the Protontricks GUI diff --git a/src/protontricks/data/scripts/wine_launch.sh b/src/protontricks/data/scripts/wine_launch.sh index 1b0a0ce..53aaed0 100644 --- a/src/protontricks/data/scripts/wine_launch.sh +++ b/src/protontricks/data/scripts/wine_launch.sh @@ -167,7 +167,7 @@ if [[ -n "$PROTONTRICKS_INSIDE_STEAM_RUNTIME" export LD_LIBRARY_PATH="$PROTON_LD_LIBRARY_PATH" log_info "LD_LIBRARY_PATH set to $LD_LIBRARY_PATH" fi - exec "$PROTON_DIST_PATH"/bin/@@name@@ "$@" || : + exec "${PROTON_BIN_PATH:-$PROTON_DIST_PATH/bin}"/@@name@@ "$@" || : elif [[ "$PROTONTRICKS_STEAM_RUNTIME" = "bwrap" ]]; then # Command is being executed outside Steam Runtime and bwrap is enabled. # Use "pressure-vessel-launch" to launch it in the existing container. diff --git a/src/protontricks/steam.py b/src/protontricks/steam.py index adf3c3d..b243f09 100644 --- a/src/protontricks/steam.py +++ b/src/protontricks/steam.py @@ -204,6 +204,31 @@ def proton_dist_path(self): except StopIteration: return None + @property + def proton_bin_path(self): + """ + Return path to the directory containing Proton's Wine executables. + None is returned if this isn't a Proton installation or the directory + doesn't exist. + + The directory is named either 'bin-arm64' or 'bin'. + + 'bin-arm64' is used by ARM64 Proton releases, which might not ship + 'bin' at all. + 'bin' is used by every other Proton release. + """ + dist_path = self.proton_dist_path + if not dist_path: + return None + + try: + return next( + (dist_path / name) for name in ("bin-arm64", "bin") + if (dist_path / name).is_dir() + ) + except StopIteration: + return None + @classmethod def from_appmanifest(cls, path, steam_lib_paths, steam_path=None): """ @@ -686,25 +711,61 @@ def get_appinfo_sections(path): return list(iter_appinfo_sections(path)) -def get_tool_appid(compat_tool_name, steam_play_manifest): +def _get_compat_tools_section(section): + """ + Return the 'compat_tools' mapping in an appinfo.vdf section, or None if + the section doesn't declare any compatibility tools + """ + try: + compat_tools = section["appinfo"]["extended"]["compat_tools"] + except (KeyError, TypeError): + return None + + return compat_tools if isinstance(compat_tools, dict) else None + + +def get_tool_appid(compat_tool_name, steam_play_manifests): """ Get the App ID for compatibility tool by the compat tool name used in STEAM_DIR/config/config.vdf + + Compatibility tools are declared across multiple Steam Play manifests. + Besides the main manifest, Valve ships separate manifests for eg. ARM64 + compatibility tools, so all of them are searched. """ - compat_tools = steam_play_manifest["appinfo"]["extended"]["compat_tools"] + entries = [] - for default_name, entry in compat_tools.items(): - # A single compatibility tool may have multiple valid names - # eg. "proton_316" and "proton_316_beta" - aliases = [default_name] + for manifest in steam_play_manifests: + compat_tools = _get_compat_tools_section(manifest) + if not compat_tools: + continue - # Each compat tool entry can also contain an 'aliases' field - # with a different compat tool name - if "aliases" in entry: - aliases += entry["aliases"].split(",") + for default_name, entry in compat_tools.items(): + # A single compatibility tool may have multiple valid names + # eg. "proton_316" and "proton_316_beta" + aliases = [] - logger.debug("%s has compat tool aliases %s", default_name, aliases) + # Each compat tool entry can also contain an 'aliases' field + # with a different compat tool name + if "aliases" in entry: + aliases += entry["aliases"].split(",") + logger.debug( + "%s has compat tool aliases %s", default_name, + [default_name] + aliases + ) + + entries.append((default_name, aliases, entry)) + + # Prefer an exact match on the tool's own name before falling back to + # aliases. Aliases are not unique between manifests: for example, + # 'proton-experimental' is an alias for both the x86_64 and the ARM64 + # Proton Experimental. + for default_name, _, entry in entries: + if compat_tool_name == default_name: + return entry["appid"] + + for _, aliases, entry in entries: if compat_tool_name in aliases: return entry["appid"] @@ -722,8 +783,8 @@ def find_steam_compat_tool_app(steam_path, steam_apps, appid=None): The compatibility tool *may* not be a Proton installation. This can be checked using `SteamApp.is_proton`. """ - def _get_tool_app(compat_tool_name, steam_apps, steam_play_manifest): - tool_appid = get_tool_appid(compat_tool_name, steam_play_manifest) + def _get_tool_app(compat_tool_name, steam_apps, steam_play_manifests): + tool_appid = get_tool_appid(compat_tool_name, steam_play_manifests) if not tool_appid: return None @@ -755,12 +816,25 @@ def _get_tool_app(compat_tool_name, steam_apps, steam_play_manifest): appinfo_sections = [ section for section in iter_appinfo_sections(appinfo_path) if section["appinfo"]["appid"] in (STEAM_PLAY_MANIFESTS_APPID, appid) + or _get_compat_tools_section(section) ] steam_play_manifest = next( section for section in appinfo_sections if section["appinfo"]["appid"] == STEAM_PLAY_MANIFESTS_APPID ) + # Compatibility tools are spread across several manifests. Keep the + # main manifest first so that its names win any ambiguity, preserving + # the behavior for setups that only use the main manifest. + steam_play_manifests = [steam_play_manifest] + sorted( + ( + section for section in appinfo_sections + if _get_compat_tools_section(section) + and section["appinfo"]["appid"] != STEAM_PLAY_MANIFESTS_APPID + ), + key=lambda section: section["appinfo"]["appid"] + ) + try: app_section = next( section for section in appinfo_sections @@ -912,7 +986,7 @@ def _get_tool_app(compat_tool_name, steam_apps, steam_play_manifest): tool_app = _get_tool_app( compat_tool_name=compat_tool_name, steam_apps=steam_apps, - steam_play_manifest=steam_play_manifest + steam_play_manifests=steam_play_manifests ) if tool_app: diff --git a/src/protontricks/util.py b/src/protontricks/util.py index 9b53331..12f6cd4 100644 --- a/src/protontricks/util.py +++ b/src/protontricks/util.py @@ -28,7 +28,8 @@ # New names "Steam Linux Runtime 2.0 (soldier)", "Steam Linux Runtime 3.0 (sniper)", - "Steam Linux Runtime 4.0" + "Steam Linux Runtime 4.0", + "Steam Linux Runtime 4.0 - Arm64" ] OS_RELEASE_PATHS = [ @@ -207,7 +208,7 @@ def create_wine_bin_dir(proton_app, use_bwrap=True): using Steam Runtime and Proton's own libraries instead of the system libraries """ - binaries = list((proton_app.proton_dist_path / "bin").iterdir()) + binaries = list(proton_app.proton_bin_path.iterdir()) # Create the base directory containing files for every Proton installation base_path = get_cache_dir() / "proton" @@ -516,7 +517,7 @@ def run_command( ]) wine_environ["PATH"] = "".join([ - str(proton_app.proton_dist_path / "bin"), os.pathsep, + str(proton_app.proton_bin_path), os.pathsep, wine_environ["PATH"] ]) @@ -524,6 +525,7 @@ def run_command( # Wine helper scripts, but other scripts could use it as well. wine_environ["PROTON_PATH"] = str(proton_app.install_path) wine_environ["PROTON_DIST_PATH"] = str(proton_app.proton_dist_path) + wine_environ["PROTON_BIN_PATH"] = str(proton_app.proton_bin_path) wine_environ["STEAM_APP_PATH"] = str(steam_app.install_path) wine_environ["STEAM_APPID"] = str(steam_app.appid) @@ -612,7 +614,7 @@ def run_command( ) wine_environ["WINE"] = str(wine_bin_dir / "wine") wine_environ["WINE_BIN"] = str( - proton_app.proton_dist_path / "bin" / "wine" + proton_app.proton_bin_path / "wine" ) wine_environ["WINELOADER"] = wine_environ["WINE"] @@ -624,7 +626,7 @@ def run_command( ) wine_environ["WINESERVER"] = str(wine_bin_dir / "wineserver") wine_environ["WINESERVER_BIN"] = str( - proton_app.proton_dist_path / "bin" / "wineserver" + proton_app.proton_bin_path / "wineserver" ) temp_dir = Path(tempfile.mkdtemp(prefix="protontricks-")) diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 46c8794..b693727 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -178,7 +178,7 @@ def test_run_winetricks_steam_runtime_v1( content = path.read_text() # Correct binary names used in the scripts - assert f"\"$PROTON_DIST_PATH\"/bin/{name}" in content + assert f"\"${{PROTON_BIN_PATH:-$PROTON_DIST_PATH/bin}}\"/{name}" in content def test_run_winetricks_steam_runtime_v2( self, cli, home_dir, steam_app_factory, steam_runtime_dir, @@ -244,7 +244,7 @@ def test_run_winetricks_steam_runtime_v2( content = path.read_text() # Correct binary names used in the scripts - assert f"\"$PROTON_DIST_PATH\"/bin/{name}" in content + assert f"\"${{PROTON_BIN_PATH:-$PROTON_DIST_PATH/bin}}\"/{name}" in content def test_run_winetricks_steam_runtime_v2_no_bwrap( self, cli, home_dir, steam_app_factory, steam_runtime_dir, @@ -310,7 +310,7 @@ def test_run_winetricks_steam_runtime_v2_no_bwrap( content = path.read_text() - assert f"\"$PROTON_DIST_PATH\"/bin/{name}" in content + assert f"\"${{PROTON_BIN_PATH:-$PROTON_DIST_PATH/bin}}\"/{name}" in content @pytest.mark.parametrize( "args,wineserver_launched", diff --git a/tests/test_steam.py b/tests/test_steam.py index 5df9c22..d86df86 100644 --- a/tests/test_steam.py +++ b/tests/test_steam.py @@ -12,7 +12,8 @@ find_steam_installations, find_steam_path, get_custom_compat_tool_installations, get_custom_windows_shortcuts, get_steam_apps, - get_steam_lib_paths, iter_appinfo_sections) + get_steam_lib_paths, get_tool_appid, + iter_appinfo_sections) class TestSteamApp: @@ -178,6 +179,29 @@ def test_steam_app_proton_dist_path(self, default_proton): shutil.rmtree(str(default_proton.install_path / "files")) assert default_proton.proton_dist_path is None + def test_steam_app_proton_bin_path(self, default_proton): + """ + Check that the correct directory containing Proton's Wine executables + is found using the `SteamApp.proton_bin_path` property + """ + dist_path = default_proton.proton_dist_path + + # 'bin' exists and is found correctly + assert default_proton.proton_bin_path == dist_path / "bin" + + # ARM64 Proton ships 'bin-arm64' instead, which is favored over 'bin' + (dist_path / "bin-arm64").mkdir() + (dist_path / "bin-arm64" / "wine").touch() + assert default_proton.proton_bin_path == dist_path / "bin-arm64" + + # Official Valve ARM64 builds ship 'bin-arm64' only + shutil.rmtree(str(dist_path / "bin")) + assert default_proton.proton_bin_path == dist_path / "bin-arm64" + + # If neither exists, None is returned + shutil.rmtree(str(dist_path / "bin-arm64")) + assert default_proton.proton_bin_path is None + def test_steam_app_userconfig_name(self, steam_app_factory): """ Try creating a SteamApp from an older version of the app manifest @@ -257,6 +281,84 @@ def teststeam_appmanifest_stateflags_uninstalled(self, steam_app_factory): +def _manifest(appid, compat_tools): + return {"appinfo": {"appid": appid, "extended": { + "compat_tools": compat_tools + }}} + + +class TestGetToolAppid: + """ + Compatibility tools are declared across several Steam Play manifests. + Valve ships ARM64 compatibility tools in a manifest of their own. + """ + MAIN = _manifest(891390, { + "proton_experimental": { + "appid": 1493710, "aliases": "proton-experimental" + }, + "proton_11": {"appid": 4628710, "aliases": "proton-stable"}, + }) + ARM64 = _manifest(3043620, { + "proton-experimental-arm64": { + "appid": 4427310, "aliases": "proton-experimental" + }, + "proton_11-arm64": { + "appid": 4628740, "aliases": "proton-stable-arm64,proton-stable" + }, + }) + + def test_tool_from_secondary_manifest(self): + """ + An ARM64 compat tool is found even though it is declared in a + manifest other than the main one + """ + assert get_tool_appid( + "proton-experimental-arm64", [self.MAIN, self.ARM64] + ) == 4427310 + assert get_tool_appid( + "proton_11-arm64", [self.MAIN, self.ARM64] + ) == 4628740 + + def test_ambiguous_alias_resolves_to_main_manifest(self): + """ + 'proton-stable' is an alias in both manifests, while + 'proton-stable-arm64' is an alias only in the ARM64 manifest + """ + # Ambiguous alias resolves to the main manifest + assert get_tool_appid( + "proton-stable", [self.MAIN, self.ARM64] + ) == 4628710 + # Unambiguous alias resolves to the ARM64 manifest + assert get_tool_appid( + "proton-stable-arm64", [self.MAIN, self.ARM64] + ) == 4628740 + + def test_exact_name_preferred_over_alias(self): + """ + A tool's own name takes precedence over the same name used as an + alias by a tool in an earlier manifest + """ + main = _manifest(891390, { + "some_tool": {"appid": 100, "aliases": "shared-name"} + }) + other = _manifest(3043620, { + "shared-name": {"appid": 200, "aliases": ""} + }) + + assert get_tool_appid("shared-name", [main, other]) == 200 + + def test_main_manifest_unaffected(self): + """ + Tool names in the main manifest keep resolving as before + """ + for name in ("proton_experimental", "proton-experimental"): + assert get_tool_appid(name, [self.MAIN, self.ARM64]) == 1493710 + + def test_unknown_tool(self): + assert get_tool_appid("does-not-exist", [self.MAIN, self.ARM64]) \ + is None + + class TestFindSteamCompatToolApp: def test_find_steam_specific_app_proton( self, steam_app_factory, steam_dir, default_proton, From 4d9e5f7d67c622c9eb0d9851f847ebb981b34791 Mon Sep 17 00:00:00 2001 From: Janne Pulkkinen Date: Thu, 24 Sep 2026 19:30:36 +0300 Subject: [PATCH 2/6] Remove os-release retrieval repetition We will be adding one more function for Steam Frame detection --- src/protontricks/util.py | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/protontricks/util.py b/src/protontricks/util.py index 12f6cd4..a1f0f51 100644 --- a/src/protontricks/util.py +++ b/src/protontricks/util.py @@ -55,19 +55,30 @@ def _lower_value(value): return {k.lower(): _lower_value(v) for k, v in d.items()} -def is_steam_deck(): - """ - Check if we're running on a Steam Deck - """ +def _get_os_release_lines(): + lines = [] + for path in OS_RELEASE_PATHS: try: lines = Path(path).read_text("utf-8").split("\n") except FileNotFoundError: continue - if "ID=steamos" in lines and "VARIANT_ID=steamdeck" in lines: - logger.info("The current device is a Steam Deck") - return True + # Remove quotes from values, just in case. + # VARIANT_ID is quoted on Steam Frame, but unquoted on Steam Deck. + lines = [line.replace('"', '').replace("'", '') for line in lines] + return lines + + +def is_steam_deck(): + """ + Check if we're running on a Steam Deck + """ + lines = _get_os_release_lines() + + if "ID=steamos" in lines and "VARIANT_ID=steamdeck" in lines: + logger.info("The current device is a Steam Deck") + return True return False @@ -76,16 +87,12 @@ def is_steamos(): """ Check if we're running on SteamOS 3 (or newer) """ - for path in OS_RELEASE_PATHS: - try: - lines = Path(path).read_text("utf-8").split("\n") - except FileNotFoundError: - continue + lines = _get_os_release_lines() - # This will not detect SteamOS 2 or older which are based on Debian - if "ID=steamos" in lines and "ID_LIKE=arch" in lines: - logger.info("The current device is running on SteamOS 3+") - return True + # This will not detect SteamOS 2 or older which are based on Debian + if "ID=steamos" in lines and "ID_LIKE=arch" in lines: + logger.info("The current device is running on SteamOS 3+") + return True return False From 2be234dd1e8e16fc7989311ac172dcef02ba0ea8 Mon Sep 17 00:00:00 2001 From: Janne Pulkkinen Date: Thu, 24 Sep 2026 19:37:26 +0300 Subject: [PATCH 3/6] Add Steam Frame detection Steam now has Steam Frame specific compatibility profiles that will (presumably) only be used on Steam Frame. Add a function to detect this. --- src/protontricks/util.py | 23 +++++++++++++++++++---- tests/conftest.py | 22 ++++++++++++++++++++++ tests/test_util.py | 16 +++++++++++++++- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/protontricks/util.py b/src/protontricks/util.py index a1f0f51..bde6a90 100644 --- a/src/protontricks/util.py +++ b/src/protontricks/util.py @@ -12,10 +12,11 @@ __all__ = ( "SUPPORTED_STEAM_RUNTIMES", "OS_RELEASE_PATHS", "lower_dict", - "is_steam_deck", "is_steamos", "get_legacy_runtime_library_paths", - "get_host_library_paths", "RUNTIME_ROOT_GLOB_PATTERNS", - "get_runtime_library_paths", "WINE_SCRIPT_TEMPLATE", - "get_cache_dir", "create_wine_bin_dir", "run_command" + "is_steam_deck", "is_steam_frame", "is_steamos", + "get_legacy_runtime_library_paths", "get_host_library_paths", + "RUNTIME_ROOT_GLOB_PATTERNS", "get_runtime_library_paths", + "WINE_SCRIPT_TEMPLATE", "get_cache_dir", "create_wine_bin_dir", + "run_command" ) logger = logging.getLogger("protontricks") @@ -83,6 +84,20 @@ def is_steam_deck(): return False +def is_steam_frame(): + """ + Check if we're running on a Steam Frame + """ + lines = _get_os_release_lines() + + # TODO: No idea if 'steamframe' is the actual variant ID + if "ID=steamos" in lines and "VARIANT_ID=vr" in lines: + logger.info("The current device is a Steam Frame") + return True + + return False + + def is_steamos(): """ Check if we're running on SteamOS 3 (or newer) diff --git a/tests/conftest.py b/tests/conftest.py index 74421e7..61e077d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -945,6 +945,28 @@ def steam_deck(monkeypatch, tmp_path): ) +@pytest.fixture(scope="function") +def steam_frame(monkeypatch, tmp_path): + """ + Mock a Steam Frame environment + """ + os_release_path = tmp_path / "etc" / "os-release" + os_release_path.parent.mkdir(parents=True) + + os_release_path.write_text("\n".join([ + 'NAME="SteamOS"', + "ID=steamos", + "ID_LIKE=arch", + "VARIANT_ID=\"vr\"" + ])) + + monkeypatch.setattr( + "protontricks.util.OS_RELEASE_PATHS", + [str(tmp_path / "etc" / "os-release")] + ) + + + def _run_cli(monkeypatch, capsys, cli_func): """ Run protontricks with the given arguments and environment variables diff --git a/tests/test_util.py b/tests/test_util.py index 2bff86e..de54b59 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -4,7 +4,8 @@ import pytest -from protontricks.util import (create_wine_bin_dir, is_steam_deck, is_steamos, +from protontricks.util import (create_wine_bin_dir, is_steam_deck, + is_steam_frame, is_steamos, lower_dict, run_command) @@ -390,6 +391,19 @@ def test_is_steam_deck(self): """ assert is_steam_deck() + def test_is_not_steam_frame(self): + """ + Test that non-Steam Frame environment is detected correctly + """ + assert not is_steam_frame() + + @pytest.mark.usefixtures("steam_frame") + def test_is_steam_frame(self): + """ + Test that Steam Frame environment is detected correctly + """ + assert is_steam_frame() + def test_not_steamos(self): """ Test that non-SteamOS environment is detected correctly From 462bf4c6d6feb250cf1f6c3c5d36e791f0a2971b Mon Sep 17 00:00:00 2001 From: Janne Pulkkinen Date: Thu, 24 Sep 2026 20:03:42 +0300 Subject: [PATCH 4/6] Implement Proton discovery on Steam Frame Same as with Steam Deck, Valve has created compatibility profiles for certain games that determine what Proton version should be used by default. --- CHANGELOG.md | 1 + src/protontricks/steam.py | 30 ++++++++++++++++++++---------- tests/test_steam.py | 20 +++++++++++++------- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cfc9c2..96642c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added - Add support for ARM64 Proton installations + - Add support for Steam Frame game compatibility profiles for Proton discovery - Add desktop file associations for batch files and Internet shortcuts - Add support for passing Winetricks arguments after `--` when using the Protontricks GUI diff --git a/src/protontricks/steam.py b/src/protontricks/steam.py index b243f09..4afa1cb 100644 --- a/src/protontricks/steam.py +++ b/src/protontricks/steam.py @@ -12,7 +12,7 @@ import vdf from ._vdf import binary_loads as vendored_binary_loads -from .util import is_steam_deck, lower_dict +from .util import is_steam_deck, is_steam_frame, lower_dict __all__ = ( "COMMON_STEAM_DIRS", "SteamApp", "find_steam_installations", @@ -887,24 +887,34 @@ def _get_tool_app(compat_tool_name, steam_apps, steam_play_manifests): ) potential_names.append(tool_name) - # Steam Deck compatibility profile has the 2nd highest priority - if app_section and is_steam_deck(): - logger.info( - "We're on a Steam Deck, checking if compatibility profile is " - "available for the app" - ) + # Steam Deck/Frame compatibility profile has the 2nd highest priority + if app_section: + device_compatibility_key = None + if is_steam_deck(): + device_compatibility_key = "steam_deck_compatibility" + logger.info( + "We're on a Steam Deck, checking if compatibility profile is " + "available for the app" + ) + elif is_steam_frame(): + device_compatibility_key = "steam_frame_compatibility" + logger.info( + "We're on a Steam Frame, checking if compatibility profile is " + "available for the app" + ) + recommended_runtime = ( app_section["appinfo"] .get("common", {}) - .get("steam_deck_compatibility", {}) + .get(device_compatibility_key, {}) .get("configuration", {}) .get("recommended_runtime", None) ) if recommended_runtime not in (None, "native"): logger.info( - "App has Steam Deck compatibility profile with Proton " - "version: %s", + "App has Valve device-specific compatibility profile " + "with Proton version: %s", recommended_runtime ) potential_names.append(recommended_runtime) diff --git a/tests/test_steam.py b/tests/test_steam.py index d86df86..4cc8738 100644 --- a/tests/test_steam.py +++ b/tests/test_steam.py @@ -435,12 +435,13 @@ def test_find_legacy_tool_mapping_global( ) assert proton_app.name == "Proton B" - @pytest.mark.usefixtures("steam_deck", "info_logging") - def test_find_steam_deck_profile( - self, steam_app_factory, proton_factory, appinfo_factory, - default_proton, steam_config_path, steam_dir): + @pytest.mark.usefixtures("info_logging") + @pytest.mark.parametrize("device", ["steam_deck", "steam_frame"]) + def test_find_device_specific_profile( + self, request, steam_app_factory, proton_factory, appinfo_factory, + default_proton, steam_config_path, steam_dir, device): """ - Create a Steam Deck compatibility profile for a game and ensure + Create a device-specific compatibility profile for a game and ensure that it is used if `config.vdf` doesn't contain any configuration """ custom_proton = proton_factory( @@ -449,12 +450,17 @@ def test_find_steam_deck_profile( steam_app_factory(name="Fake game", appid=10) - # Add Steam Deck compatibility profile + if device == "steam_deck": + request.getfixturevalue("steam_deck") + elif device == "steam_frame": + request.getfixturevalue("steam_frame") + + # Add device-specific compatibility profile appinfo_factory( appid=10, appinfo={ "common": { - "steam_deck_compatibility": { + f"{device}_compatibility": { "configuration": { "recommended_runtime": "proton_7_77" } From f6405356eec7c2bf2a9106e26c62678409b0dd92 Mon Sep 17 00:00:00 2001 From: Matthew Schwartz Date: Thu, 24 Sep 2026 12:58:23 -0700 Subject: [PATCH 5/6] Prefer compatibility tools with the '-arm64' suffix on ARM64 Steam looks for a compatibility tool with the '-arm64' suffix first and falls back to the name without it. Steam Frame compatibility profiles refer to Proton versions without the suffix, so Protontricks resolved them to the x86_64 installation, which is not installed on a Steam Frame. Check for the suffixed name first on ARM64 and fall back to the original name, which is still needed for custom compatibility tools that do not use the suffix. --- src/protontricks/steam.py | 101 +++++++++++++++++++++++++++--------- src/protontricks/util.py | 10 +++- tests/conftest.py | 10 ++++ tests/test_steam.py | 104 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 25 deletions(-) diff --git a/src/protontricks/steam.py b/src/protontricks/steam.py index 4afa1cb..ebf5f52 100644 --- a/src/protontricks/steam.py +++ b/src/protontricks/steam.py @@ -12,7 +12,7 @@ import vdf from ._vdf import binary_loads as vendored_binary_loads -from .util import is_steam_deck, is_steam_frame, lower_dict +from .util import is_arm64, is_steam_deck, is_steam_frame, lower_dict __all__ = ( "COMMON_STEAM_DIRS", "SteamApp", "find_steam_installations", @@ -724,17 +724,12 @@ def _get_compat_tools_section(section): return compat_tools if isinstance(compat_tools, dict) else None -def get_tool_appid(compat_tool_name, steam_play_manifests): +def _iter_compat_tool_entries(steam_play_manifests): """ - Get the App ID for compatibility tool by the compat tool name - used in STEAM_DIR/config/config.vdf - - Compatibility tools are declared across multiple Steam Play manifests. - Besides the main manifest, Valve ships separate manifests for eg. ARM64 - compatibility tools, so all of them are searched. + Iterate the compatibility tools declared in the given Steam Play + manifests, yielding the tool's own name, every name it is known by, and + the entry itself """ - entries = [] - for manifest in steam_play_manifests: compat_tools = _get_compat_tools_section(manifest) if not compat_tools: @@ -742,31 +737,65 @@ def get_tool_appid(compat_tool_name, steam_play_manifests): for default_name, entry in compat_tools.items(): # A single compatibility tool may have multiple valid names - # eg. "proton_316" and "proton_316_beta" - aliases = [] - - # Each compat tool entry can also contain an 'aliases' field - # with a different compat tool name + # eg. "proton_316" and "proton_316_beta". Each compat tool entry + # can also contain an 'aliases' field with a different compat + # tool name + names = [default_name] if "aliases" in entry: - aliases += entry["aliases"].split(",") + names += entry["aliases"].split(",") + + yield default_name, names, entry + + +def _get_arm64_tool_name(compat_tool_name, steam_play_manifests): + """ + Get the name of the ARM64 compatibility tool corresponding to the given + compatibility tool name, or None if there is none + + Corresponding tools are not named consistently between manifests. The + ARM64 counterpart of 'proton_experimental' is + 'proton-experimental-arm64', and the two are only connected by the + 'proton-experimental' alias they have in common. + """ + entries = list(_iter_compat_tool_entries(steam_play_manifests)) + + names = {compat_tool_name} + for _, tool_names, _entry in entries: + if compat_tool_name in tool_names: + names = set(tool_names) + break + + for default_name, tool_names, _entry in entries: + if default_name.endswith("-arm64") and names & set(tool_names): + return default_name + + return None - logger.debug( - "%s has compat tool aliases %s", default_name, - [default_name] + aliases - ) - entries.append((default_name, aliases, entry)) +def get_tool_appid(compat_tool_name, steam_play_manifests): + """ + Get the App ID for compatibility tool by the compat tool name + used in STEAM_DIR/config/config.vdf + + Compatibility tools are declared across multiple Steam Play manifests. + Besides the main manifest, Valve ships separate manifests for eg. ARM64 + compatibility tools, so all of them are searched. + """ + entries = list(_iter_compat_tool_entries(steam_play_manifests)) + + for default_name, names, _entry in entries: + logger.debug("%s has compat tool aliases %s", default_name, names) # Prefer an exact match on the tool's own name before falling back to # aliases. Aliases are not unique between manifests: for example, # 'proton-experimental' is an alias for both the x86_64 and the ARM64 # Proton Experimental. - for default_name, _, entry in entries: + for default_name, _names, entry in entries: if compat_tool_name == default_name: return entry["appid"] - for _, aliases, entry in entries: - if compat_tool_name in aliases: + for _default_name, names, entry in entries: + if compat_tool_name in names: return entry["appid"] return None @@ -971,6 +1000,30 @@ def _get_tool_app(compat_tool_name, steam_apps, steam_play_manifests): ) compat_tool_names = ["proton-experimental", "proton-stable"] + # Steam looks for a compatibility tool with the '-arm64' suffix first on + # ARM64 and falls back to the original name if there is none. + if is_arm64(): + arm64_names = [] + + for compat_tool_name in compat_tool_names: + candidates = [] + + if not compat_tool_name.endswith("-arm64"): + candidates.append(f"{compat_tool_name}-arm64") + + # The ARM64 tool is not always named after the configured tool, + # so also look for the tool it corresponds to + candidates.append( + _get_arm64_tool_name(compat_tool_name, steam_play_manifests) + ) + candidates.append(compat_tool_name) + + for candidate in candidates: + if candidate and candidate not in arm64_names: + arm64_names.append(candidate) + + compat_tool_names = arm64_names + # We've got a compatibility tool name, # now there are two possible ways to find the installation # 1. It's a custom compatibility tool, and we simply need to find diff --git a/src/protontricks/util.py b/src/protontricks/util.py index bde6a90..845d6ec 100644 --- a/src/protontricks/util.py +++ b/src/protontricks/util.py @@ -3,6 +3,7 @@ import locale import logging import os +import platform import shlex import shutil import stat @@ -12,7 +13,7 @@ __all__ = ( "SUPPORTED_STEAM_RUNTIMES", "OS_RELEASE_PATHS", "lower_dict", - "is_steam_deck", "is_steam_frame", "is_steamos", + "is_arm64", "is_steam_deck", "is_steam_frame", "is_steamos", "get_legacy_runtime_library_paths", "get_host_library_paths", "RUNTIME_ROOT_GLOB_PATTERNS", "get_runtime_library_paths", "WINE_SCRIPT_TEMPLATE", "get_cache_dir", "create_wine_bin_dir", @@ -71,6 +72,13 @@ def _get_os_release_lines(): return lines +def is_arm64(): + """ + Check if we're running on an ARM64 platform + """ + return platform.machine() == "aarch64" + + def is_steam_deck(): """ Check if we're running on a Steam Deck diff --git a/tests/conftest.py b/tests/conftest.py index 61e077d..adabb7a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -945,6 +945,16 @@ def steam_deck(monkeypatch, tmp_path): ) +@pytest.fixture(scope="function") +def arm64(monkeypatch): + """ + Mock an ARM64 environment + """ + monkeypatch.setattr( + "protontricks.util.platform.machine", lambda: "aarch64" + ) + + @pytest.fixture(scope="function") def steam_frame(monkeypatch, tmp_path): """ diff --git a/tests/test_steam.py b/tests/test_steam.py index 4cc8738..3d4b374 100644 --- a/tests/test_steam.py +++ b/tests/test_steam.py @@ -384,6 +384,110 @@ def test_find_steam_specific_app_proton( # version for this game assert proton_app.name == "Proton 6.66" + @pytest.mark.usefixtures("arm64") + def test_find_arm64_compat_tool( + self, steam_app_factory, steam_dir, proton_factory): + """ + Check that the compatibility tool with the '-arm64' suffix is + preferred on ARM64 + """ + proton = proton_factory( + name="Proton 11.0", appid=100, compat_tool_name="proton-stable" + ) + arm64_proton = proton_factory( + name="Proton 11.0 (ARM64)", appid=200, + compat_tool_name="proton-stable-arm64" + ) + steam_app_factory( + name="Fake game", appid=10, compat_tool_name="proton-stable" + ) + + proton_app = find_steam_compat_tool_app( + steam_path=steam_dir, + steam_apps=[proton, arm64_proton], + appid=10 + ) + + assert proton_app.name == "Proton 11.0 (ARM64)" + + @pytest.mark.usefixtures("arm64") + def test_find_arm64_compat_tool_differing_name( + self, steam_app_factory, steam_dir, proton_factory): + """ + Check that the corresponding ARM64 compatibility tool is found even + when its name is not derived from the configured name + """ + proton = proton_factory( + name="Proton Experimental", appid=100, + compat_tool_name="proton_experimental", + aliases=["proton-experimental"], is_default_proton=False + ) + arm64_proton = proton_factory( + name="Proton Experimental (ARM64)", appid=200, + compat_tool_name="proton-experimental-arm64", + aliases=["proton-experimental"], is_default_proton=False + ) + steam_app_factory( + name="Fake game", appid=10, + compat_tool_name="proton_experimental" + ) + + proton_app = find_steam_compat_tool_app( + steam_path=steam_dir, + steam_apps=[proton, arm64_proton], + appid=10 + ) + + assert proton_app.name == "Proton Experimental (ARM64)" + + @pytest.mark.usefixtures("arm64") + def test_find_arm64_compat_tool_already_suffixed( + self, steam_app_factory, steam_dir, proton_factory): + """ + Check that a compatibility tool name that already has the '-arm64' + suffix is resolved as-is + """ + arm64_proton = proton_factory( + name="Proton 11.0 (ARM64)", appid=200, + compat_tool_name="proton-stable-arm64" + ) + steam_app_factory( + name="Fake game", appid=10, + compat_tool_name="proton-stable-arm64" + ) + + proton_app = find_steam_compat_tool_app( + steam_path=steam_dir, + steam_apps=[arm64_proton], + appid=10 + ) + + assert proton_app.name == "Proton 11.0 (ARM64)" + + def test_find_compat_tool_not_arm64( + self, steam_app_factory, steam_dir, proton_factory): + """ + Check that the '-arm64' suffix is not used outside of ARM64 + """ + proton = proton_factory( + name="Proton 11.0", appid=100, compat_tool_name="proton-stable" + ) + arm64_proton = proton_factory( + name="Proton 11.0 (ARM64)", appid=200, + compat_tool_name="proton-stable-arm64" + ) + steam_app_factory( + name="Fake game", appid=10, compat_tool_name="proton-stable" + ) + + proton_app = find_steam_compat_tool_app( + steam_path=steam_dir, + steam_apps=[proton, arm64_proton], + appid=10 + ) + + assert proton_app.name == "Proton 11.0" + @pytest.mark.usefixtures("info_logging") def test_find_legacy_tool_mapping_global( self, steam_dir, steam_config_path, proton_factory, From e0f40b98b0f8c8484f51f83b1a287caf3946ae04 Mon Sep 17 00:00:00 2001 From: Matthew Schwartz Date: Thu, 24 Sep 2026 12:58:32 -0700 Subject: [PATCH 6/6] Confirm the Steam Frame variant ID A Steam Frame running SteamOS 0.5.0 has `VARIANT_ID="vr"` in /etc/os-release, so the existing check is correct. --- src/protontricks/util.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/protontricks/util.py b/src/protontricks/util.py index 845d6ec..0a20b41 100644 --- a/src/protontricks/util.py +++ b/src/protontricks/util.py @@ -98,7 +98,6 @@ def is_steam_frame(): """ lines = _get_os_release_lines() - # TODO: No idea if 'steamframe' is the actual variant ID if "ID=steamos" in lines and "VARIANT_ID=vr" in lines: logger.info("The current device is a Steam Frame") return True