diff --git a/CHANGELOG.md b/CHANGELOG.md index 266f170..96642c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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/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..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, lower_dict +from .util import is_arm64, is_steam_deck, is_steam_frame, lower_dict __all__ = ( "COMMON_STEAM_DIRS", "SteamApp", "find_steam_installations", @@ -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,26 +711,91 @@ 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 _iter_compat_tool_entries(steam_play_manifests): + """ + 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 + """ + for manifest in steam_play_manifests: + compat_tools = _get_compat_tools_section(manifest) + if not compat_tools: + continue + + for default_name, entry in compat_tools.items(): + # A single compatibility tool may have multiple valid names + # 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: + 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 + + +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 - """ - compat_tools = steam_play_manifest["appinfo"]["extended"]["compat_tools"] - 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] + 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)) - # 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, names, _entry in entries: + logger.debug("%s has compat tool aliases %s", default_name, names) - logger.debug("%s has compat tool aliases %s", default_name, aliases) + # 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, _names, entry in entries: + if compat_tool_name == default_name: + return entry["appid"] - if compat_tool_name in aliases: + for _default_name, names, entry in entries: + if compat_tool_name in names: return entry["appid"] return None @@ -722,8 +812,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 +845,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 @@ -813,24 +916,34 @@ def _get_tool_app(compat_tool_name, steam_apps, steam_play_manifest): ) 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) @@ -887,6 +1000,30 @@ def _get_tool_app(compat_tool_name, steam_apps, steam_play_manifest): ) 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 @@ -912,7 +1049,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..0a20b41 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,10 +13,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_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", + "run_command" ) logger = logging.getLogger("protontricks") @@ -28,7 +30,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 = [ @@ -54,19 +57,50 @@ 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_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 + """ + 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 + + +def is_steam_frame(): + """ + Check if we're running on a Steam Frame + """ + lines = _get_os_release_lines() + + if "ID=steamos" in lines and "VARIANT_ID=vr" in lines: + logger.info("The current device is a Steam Frame") + return True return False @@ -75,16 +109,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 @@ -207,7 +237,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 +546,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 +554,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 +643,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 +655,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/conftest.py b/tests/conftest.py index 74421e7..adabb7a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -945,6 +945,38 @@ 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): + """ + 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_steam.py b/tests/test_steam.py index 5df9c22..3d4b374 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, @@ -282,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, @@ -333,12 +539,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( @@ -347,12 +554,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" } 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