From efddeddbc4a4755e881d5e734f801cbe927d4524 Mon Sep 17 00:00:00 2001 From: felipefl142 Date: Wed, 15 Apr 2026 02:50:07 -0300 Subject: [PATCH 01/12] feat: add Steam Proton launcher for Linux (closes #128) Implements ProtonLauncher for running Balatro via Steam Proton on Linux. Previously, `get_launcher()` raised `NotImplementedError` on Linux. This adds full support via Steam's Proton compatibility layer. ## How it works Balatro runs as a Windows executable under Wine/Proton. The launcher: - Invokes Proton as: `proton run Balatro.exe` - Sets `WINEDLLOVERRIDES=version=n,b` so Wine loads lovely-injector's `version.dll` DLL hijack (same mechanism as the Windows launcher) - Sets `STEAM_COMPAT_DATA_PATH` and `STEAM_COMPAT_CLIENT_INSTALL_PATH` required by Proton ## Auto-detection All paths are auto-detected if not explicitly configured: - **Balatro**: `~/.local/share/Steam/steamapps/common/Balatro` - **Proton**: scans `compatibilitytools.d/` and `steamapps/common/`, prefers GE-Proton > official Proton > Experimental by version number - **lovely-injector**: `version.dll` in the Balatro directory - **Compat data**: `steamapps/compatdata/2379780` ## Platform values Both `"linux"` (auto-detected) and `"proton"` (explicit override via `BALATROBOT_PLATFORM`) route to `ProtonLauncher`. --- src/balatrobot/platforms/__init__.py | 10 +- src/balatrobot/platforms/proton.py | 177 +++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 src/balatrobot/platforms/proton.py diff --git a/src/balatrobot/platforms/__init__.py b/src/balatrobot/platforms/__init__.py index aa8a560c..46b92300 100644 --- a/src/balatrobot/platforms/__init__.py +++ b/src/balatrobot/platforms/__init__.py @@ -6,7 +6,7 @@ if TYPE_CHECKING: from balatrobot.platforms.base import BaseLauncher -VALID_PLATFORMS = frozenset({"darwin", "linux", "windows", "native"}) +VALID_PLATFORMS = frozenset({"darwin", "linux", "proton", "windows", "native"}) def get_launcher(platform: str | None = None) -> "BaseLauncher": @@ -14,7 +14,7 @@ def get_launcher(platform: str | None = None) -> "BaseLauncher": Args: platform: Optional platform to use instead of auto-detection. - Valid values: "darwin", "linux", "windows", "native" + Valid values: "darwin", "linux", "proton", "windows", "native" Returns: Launcher instance for the platform @@ -39,8 +39,10 @@ def get_launcher(platform: str | None = None) -> "BaseLauncher": from balatrobot.platforms.macos import MacOSLauncher return MacOSLauncher() - case "linux": - raise NotImplementedError("Linux launcher not yet implemented") + case "linux" | "proton": + from balatrobot.platforms.proton import ProtonLauncher + + return ProtonLauncher() case "windows": from balatrobot.platforms.windows import WindowsLauncher diff --git a/src/balatrobot/platforms/proton.py b/src/balatrobot/platforms/proton.py new file mode 100644 index 00000000..7fda913a --- /dev/null +++ b/src/balatrobot/platforms/proton.py @@ -0,0 +1,177 @@ +"""Steam Proton launcher for Linux.""" + +import os +import re +from pathlib import Path + +from balatrobot.config import Config +from balatrobot.platforms.base import BaseLauncher + +BALATRO_APP_ID = "2379780" + + +def _detect_steam_root() -> Path | None: + """Detect the Steam installation directory.""" + candidates = [ + Path.home() / ".local/share/Steam", + Path.home() / ".steam/steam", + Path("/usr/local/share/Steam"), + ] + for p in candidates: + if (p / "steamapps").is_dir(): + return p + return None + + +def _proton_sort_key(p: Path) -> tuple: + """Sort key that prefers GE-Proton > official Proton > Experimental.""" + name = p.parent.name + m = re.match(r"GE-Proton(\d+)-(\d+)", name) + if m: + return (0, -int(m.group(1)), -int(m.group(2))) + m = re.match(r"Proton (\d+)\.(\d+)", name) + if m: + return (1, -int(m.group(1)), -int(m.group(2))) + if "Experimental" in name: + return (2, 0, 0) + return (3, 0, 0) + + +def _detect_proton_path(steam_root: Path) -> Path | None: + """Find the best available Proton executable.""" + candidates: list[Path] = [] + + # Community Proton builds (GE-Proton, etc.) + compat_dirs = [ + steam_root / "compatibilitytools.d", + Path.home() / ".steam/root/compatibilitytools.d", + ] + for compat_dir in compat_dirs: + if compat_dir.is_dir(): + for d in compat_dir.iterdir(): + proton = d / "proton" + if proton.is_file(): + candidates.append(proton) + + # Official Proton builds in steamapps/common + steamapps_common = steam_root / "steamapps/common" + if steamapps_common.is_dir(): + for d in steamapps_common.iterdir(): + proton = d / "proton" + if proton.is_file() and "proton" in d.name.lower(): + candidates.append(proton) + + if not candidates: + return None + return sorted(candidates, key=_proton_sort_key)[0] + + +def _detect_balatro_path(steam_root: Path) -> Path | None: + """Detect the Balatro game directory.""" + p = steam_root / "steamapps/common/Balatro" + return p if p.is_dir() else None + + +def _detect_lovely_path(balatro_path: Path) -> Path | None: + """Detect the lovely-injector version.dll inside the Balatro directory.""" + p = balatro_path / "version.dll" + return p if p.is_file() else None + + +def _detect_compat_data_path(steam_root: Path) -> Path | None: + """Detect the Steam compatibility data directory for Balatro.""" + p = steam_root / f"steamapps/compatdata/{BALATRO_APP_ID}" + return p if p.is_dir() else None + + +class ProtonLauncher(BaseLauncher): + """Steam Proton launcher for Balatro on Linux.""" + + def validate_paths(self, config: Config) -> None: + """Validate paths, auto-detect Steam/Proton/Balatro paths if not set.""" + errors: list[str] = [] + steam_root = _detect_steam_root() + + # --- Balatro path --- + if config.balatro_path is None and steam_root: + detected = _detect_balatro_path(steam_root) + if detected: + config.balatro_path = str(detected) + + if config.balatro_path is None: + errors.append( + "Balatro game directory is required.\n" + " Set via: --balatro-path or BALATROBOT_BALATRO_PATH\n" + " Expected: ~/.local/share/Steam/steamapps/common/Balatro" + ) + else: + balatro = Path(config.balatro_path) + if not balatro.is_dir(): + errors.append(f"Balatro game directory not found: {balatro}") + elif not (balatro / "Balatro.exe").is_file(): + errors.append(f"Balatro.exe not found in: {balatro}") + + # --- Proton path (stored in love_path) --- + if config.love_path is None and steam_root: + detected = _detect_proton_path(steam_root) + if detected: + config.love_path = str(detected) + + if config.love_path is None: + errors.append( + "Proton executable is required.\n" + " Set via: --love-path or BALATROBOT_LOVE_PATH\n" + " Expected: path to a 'proton' script inside your Proton installation" + ) + else: + proton = Path(config.love_path) + if not proton.is_file(): + errors.append(f"Proton executable not found: {proton}") + + # --- lovely-injector (version.dll) --- + if config.lovely_path is None and config.balatro_path: + detected = _detect_lovely_path(Path(config.balatro_path)) + if detected: + config.lovely_path = str(detected) + + if config.lovely_path is None: + errors.append( + "lovely-injector version.dll is required.\n" + " Set via: --lovely-path or BALATROBOT_LOVELY_PATH\n" + " Expected: ~/.local/share/Steam/steamapps/common/Balatro/version.dll" + ) + else: + lovely = Path(config.lovely_path) + if not lovely.is_file(): + errors.append(f"version.dll not found: {lovely}") + + if errors: + raise RuntimeError("Path validation failed:\n\n" + "\n\n".join(errors)) + + def build_env(self, config: Config) -> dict[str, str]: + """Build environment for Proton, including Wine DLL override for lovely-injector.""" + assert config.love_path is not None + assert config.balatro_path is not None + + env = os.environ.copy() + + # lovely-injector uses a version.dll DLL hijack — tell Wine to load it + env["WINEDLLOVERRIDES"] = "version=n,b" + + steam_root = _detect_steam_root() + if steam_root: + env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = str(steam_root) + compat_data = _detect_compat_data_path(steam_root) + if compat_data: + env["STEAM_COMPAT_DATA_PATH"] = str(compat_data) + + env.update(config.to_env()) + return env + + def build_cmd(self, config: Config) -> list[str]: + """Build Proton launch command: proton run Balatro.exe.""" + assert config.love_path is not None + assert config.balatro_path is not None + + balatro_exe = str(Path(config.balatro_path) / "Balatro.exe") + return [config.love_path, "run", balatro_exe] From f9f2f09337cd211b403d886899c82c13774052f3 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 19 Apr 2026 10:56:15 +0200 Subject: [PATCH 02/12] fix(platforms): rename 'linux' platform to 'proton' Remove the 'linux' platform name entirely. Users on Linux must now explicitly pass --platform proton. This avoids ambiguity between Proton (Steam) and native (LOVE) setups. --- src/balatrobot/cli/serve.py | 6 +++--- src/balatrobot/platforms/__init__.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/balatrobot/cli/serve.py b/src/balatrobot/cli/serve.py index 0307facd..e9a280f6 100644 --- a/src/balatrobot/cli/serve.py +++ b/src/balatrobot/cli/serve.py @@ -9,7 +9,7 @@ from balatrobot.manager import BalatroInstance # Platform choices for validation -PLATFORM_CHOICES = ["darwin", "linux", "windows", "native"] +PLATFORM_CHOICES = ["darwin", "proton", "windows", "native"] def serve( @@ -55,10 +55,10 @@ def serve( str | None, typer.Option(help="Path to lovely library") ] = None, love_path: Annotated[ - str | None, typer.Option(help="Path to LOVE executable") + str | None, typer.Option(help="Path to game launcher executable") ] = None, platform: Annotated[ - str | None, typer.Option(help="Platform (darwin, linux, windows, native)") + str | None, typer.Option(help="Platform (darwin, proton, windows, native)") ] = None, # fmt: on ) -> None: diff --git a/src/balatrobot/platforms/__init__.py b/src/balatrobot/platforms/__init__.py index 46b92300..5631f6fd 100644 --- a/src/balatrobot/platforms/__init__.py +++ b/src/balatrobot/platforms/__init__.py @@ -6,7 +6,7 @@ if TYPE_CHECKING: from balatrobot.platforms.base import BaseLauncher -VALID_PLATFORMS = frozenset({"darwin", "linux", "proton", "windows", "native"}) +VALID_PLATFORMS = frozenset({"darwin", "proton", "windows", "native"}) def get_launcher(platform: str | None = None) -> "BaseLauncher": @@ -14,7 +14,7 @@ def get_launcher(platform: str | None = None) -> "BaseLauncher": Args: platform: Optional platform to use instead of auto-detection. - Valid values: "darwin", "linux", "proton", "windows", "native" + Valid values: "darwin", "proton", "windows", "native" Returns: Launcher instance for the platform @@ -39,7 +39,7 @@ def get_launcher(platform: str | None = None) -> "BaseLauncher": from balatrobot.platforms.macos import MacOSLauncher return MacOSLauncher() - case "linux" | "proton": + case "proton": from balatrobot.platforms.proton import ProtonLauncher return ProtonLauncher() From 70a2f5d19c95a41d0d4de6ecdcb05cd7b474a17b Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 19 Apr 2026 10:56:21 +0200 Subject: [PATCH 03/12] test(platforms): add ProtonLauncher tests, remove linux test - Replace test_linux_not_implemented with test_proton_returns_proton_launcher - Add TestProtonLauncher class with 6 tests (build_cmd, build_env, validate_paths error cases) - Update PLATFORM_CHOICES assertion in test_serve_cmd --- tests/cli/test_platforms.py | 92 +++++++++++++++++++++++++++++++++++-- tests/cli/test_serve_cmd.py | 2 +- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/tests/cli/test_platforms.py b/tests/cli/test_platforms.py index 5ead2781..5189caed 100644 --- a/tests/cli/test_platforms.py +++ b/tests/cli/test_platforms.py @@ -8,6 +8,7 @@ from balatrobot.platforms import VALID_PLATFORMS, get_launcher from balatrobot.platforms.macos import MacOSLauncher from balatrobot.platforms.native import NativeLauncher +from balatrobot.platforms.proton import ProtonLauncher from balatrobot.platforms.windows import WindowsLauncher IS_MACOS = platform_module.system() == "Darwin" @@ -38,15 +39,15 @@ def test_windows_returns_windows_launcher(self): launcher = get_launcher("windows") assert isinstance(launcher, WindowsLauncher) - def test_linux_not_implemented(self): - """'linux' raises NotImplementedError.""" - with pytest.raises(NotImplementedError): - get_launcher("linux") + def test_proton_returns_proton_launcher(self): + """'proton' returns ProtonLauncher.""" + launcher = get_launcher("proton") + assert isinstance(launcher, ProtonLauncher) def test_valid_platforms_constant(self): """VALID_PLATFORMS contains expected values.""" assert "darwin" in VALID_PLATFORMS - assert "linux" in VALID_PLATFORMS + assert "proton" in VALID_PLATFORMS assert "windows" in VALID_PLATFORMS assert "native" in VALID_PLATFORMS @@ -131,6 +132,87 @@ def test_build_cmd(self, tmp_path): assert cmd == ["/usr/bin/love", "/path/to/balatro"] +class TestProtonLauncher: + """Tests for ProtonLauncher (no Linux/Proton required).""" + + def test_build_cmd(self): + """build_cmd returns proton run with Balatro.exe path.""" + launcher = ProtonLauncher() + config = Config( + love_path="/path/to/proton", + balatro_path="/path/to/Balatro", + ) + + cmd = launcher.build_cmd(config) + + assert cmd == ["/path/to/proton", "run", "/path/to/Balatro/Balatro.exe"] + + def test_build_env_includes_wine_dll_overrides(self): + """build_env includes WINEDLLOVERRIDES for lovely-injector.""" + launcher = ProtonLauncher() + config = Config( + love_path="/path/to/proton", + balatro_path="/path/to/Balatro", + ) + + env = launcher.build_env(config) + + assert env["WINEDLLOVERRIDES"] == "version=n,b" + + def test_build_env_no_other_injection_vars(self): + """build_env does not include DYLD_INSERT_LIBRARIES or LD_PRELOAD.""" + launcher = ProtonLauncher() + config = Config( + love_path="/path/to/proton", + balatro_path="/path/to/Balatro", + ) + + env = launcher.build_env(config) + + assert "DYLD_INSERT_LIBRARIES" not in env + assert "LD_PRELOAD" not in env + + def test_validate_paths_missing_balatro_exe(self, tmp_path): + """Raises RuntimeError when Balatro.exe not found.""" + launcher = ProtonLauncher() + config = Config( + love_path=str(tmp_path / "proton"), + balatro_path=str(tmp_path), + ) + # Create fake proton + (tmp_path / "proton").touch() + + with pytest.raises(RuntimeError, match="Balatro.exe not found"): + launcher.validate_paths(config) + + def test_validate_paths_missing_proton(self, tmp_path): + """Raises RuntimeError when proton executable not found.""" + launcher = ProtonLauncher() + config = Config( + love_path=str(tmp_path / "nonexistent"), + balatro_path=str(tmp_path), + ) + + with pytest.raises(RuntimeError, match="Proton executable not found"): + launcher.validate_paths(config) + + def test_validate_paths_missing_version_dll(self, tmp_path): + """Raises RuntimeError when version.dll not found.""" + # Create fake Balatro directory with Balatro.exe + (tmp_path / "Balatro.exe").touch() + (tmp_path / "proton").touch() + + launcher = ProtonLauncher() + config = Config( + love_path=str(tmp_path / "proton"), + balatro_path=str(tmp_path), + lovely_path=str(tmp_path / "nonexistent.dll"), + ) + + with pytest.raises(RuntimeError, match="version.dll not found"): + launcher.validate_paths(config) + + @pytest.mark.skipif(not IS_WINDOWS, reason="Windows only") class TestWindowsLauncher: """Tests for WindowsLauncher (Windows only).""" diff --git a/tests/cli/test_serve_cmd.py b/tests/cli/test_serve_cmd.py index 95bf4aae..671e920d 100644 --- a/tests/cli/test_serve_cmd.py +++ b/tests/cli/test_serve_cmd.py @@ -22,7 +22,7 @@ def test_serve_invalid_platform_error(self): def test_serve_valid_platforms(self): """All valid platforms in list.""" - assert PLATFORM_CHOICES == ["darwin", "linux", "windows", "native"] + assert PLATFORM_CHOICES == ["darwin", "proton", "windows", "native"] # --- Help text tests --- From db5dd9ea993febfbf096951cd01bfb649c8b0994 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Sun, 19 Apr 2026 10:56:33 +0200 Subject: [PATCH 04/12] docs: update documentation for proton platform - Add Linux (Proton) Platform section to cli.md - Update --love-path and --platform descriptions in options table - Replace 'Help Needed' warning in contributing.md with Supported Platforms section - Update CLAUDE.md Platform Abstraction description --- CLAUDE.md | 2 +- docs/cli.md | 30 ++++++++++++++++++++++++++++-- docs/contributing.md | 13 ++++++------- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 740381bb..85f7cbd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ Controls the game lifecycle and provides the CLI. - **CLI** (`cli.py`): Entry point (`balatrobot`). Handles arguments like `--fast`, `--debug`, `--headless`. - **Manager** (`manager.py`): `BalatroInstance` context manager. Starts the game process, handles logging, and waits for the API to be healthy. - **Config** (`config.py`): Configuration management using `dataclasses` and environment variables. -- **Platform Abstraction** (`platforms/`): Cross-platform game launcher system with platform-specific implementations for macOS, Windows, and native Love2D. +- **Platform Abstraction** (`platforms/`): Cross-platform game launcher system with platform-specific implementations for macOS, Windows, Linux (Proton), and native Love2D. ### 2. Lua Layer (`src/lua/`) diff --git a/docs/cli.md b/docs/cli.md index c9bb71af..240b05c7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -46,8 +46,8 @@ All options can be set via CLI flags or environment variables. CLI flags overrid | `--pixel-art-smoothing` | `BALATROBOT_PIXEL_ART_SMOOTHING` | `0` | Enable pixel art smoothing | | `--balatro-path BALATRO_PATH` | `BALATROBOT_BALATRO_PATH` | auto-detected | Path to Balatro game directory | | `--lovely-path LOVELY_PATH` | `BALATROBOT_LOVELY_PATH` | auto-detected | Path to lovely library (dll/so/dylib) | -| `--love-path LOVE_PATH` | `BALATROBOT_LOVE_PATH` | auto-detected | Path to LOVE executable (native only) | -| `--platform PLATFORM` | `BALATROBOT_PLATFORM` | auto-detected | Platform: darwin, linux, windows, native | +| `--love-path LOVE_PATH` | `BALATROBOT_LOVE_PATH` | auto-detected | Path to game launcher executable | +| `--platform PLATFORM` | `BALATROBOT_PLATFORM` | auto-detected | Platform: darwin, proton, windows, native | | `--logs-path LOGS_PATH` | `BALATROBOT_LOGS_PATH` | `logs` | Directory for log files | | `-h, --help` | - | - | Show help message and exit | @@ -223,6 +223,32 @@ uvx balatrobot serve --fast uvx balatrobot serve --love-path "/path/to/love" --lovely-path "/path/to/liblovely.dylib" ``` +### Linux (Proton) Platform + +The `proton` platform launches Balatro via Steam Proton on Linux. The CLI auto-detects Steam and Proton installation paths: + +**Auto-Detected Paths:** + +- `BALATROBOT_BALATRO_PATH`: `~/.local/share/Steam/steamapps/common/Balatro` +- `BALATROBOT_LOVE_PATH`: Best available Proton executable (prefers GE-Proton > official Proton > Experimental) +- `BALATROBOT_LOVELY_PATH`: `~/.local/share/Steam/steamapps/common/Balatro/version.dll` + +**Requirements:** + +- Balatro installed via Steam with Proton +- [Lovely Injector](https://github.com/ethangreen-dev/lovely-injector) `version.dll` (Windows version) placed in the Balatro game directory +- Mods directory: `~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods` + +**Launch:** + +```bash +# Explicit proton platform (required on Linux) +uvx balatrobot serve --platform proton --fast + +# Or specify custom paths +uvx balatrobot serve --love-path /path/to/proton --balatro-path /path/to/Balatro +``` + ### Native Platform (Linux Only) The `native` platform runs Balatro from source code using the LÖVE framework installed via package manager. This requires specific directory structure: diff --git a/docs/contributing.md b/docs/contributing.md index 088a8a37..d65d2171 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -2,15 +2,14 @@ Guide for contributing to BalatroBot development. -!!! warning "Help Needed: Linux (Proton) Support" +## Supported Platforms - We currently lack CLI support for **Linux (Proton)**. Contributions to implement this platform are highly welcome! +Platform-specific launchers are located in `src/balatrobot/platforms/`: - Please refer to the existing implementations for guidance: - - - **macOS:** `src/balatrobot/platforms/macos.py` - - **Windows:** `src/balatrobot/platforms/windows.py` - - **Linux (Native):** `src/balatrobot/platforms/native.py` +- **macOS:** `src/balatrobot/platforms/macos.py` +- **Linux (Proton):** `src/balatrobot/platforms/proton.py` +- **Windows:** `src/balatrobot/platforms/windows.py` +- **Linux (Native):** `src/balatrobot/platforms/native.py` ## Prerequisites From a2be5d9d0cf99abde5721ad70e6f0ba1cefed100 Mon Sep 17 00:00:00 2001 From: felipefl142 Date: Sun, 19 Apr 2026 21:35:12 -0300 Subject: [PATCH 05/12] fix(proton): add Flatpak/Snap Steam paths and validate STEAM_COMPAT_* vars Add Flatpak and Snap Steam installation paths to _detect_steam_root() as suggested in PR review. Validate that STEAM_COMPAT_CLIENT_INSTALL_PATH and STEAM_COMPAT_DATA_PATH are resolvable in validate_paths(), failing with a clear error if steam_root is undetected and the vars are absent from the environment. Tested: proton run Balatro.exe works with GE-Proton10-34 and Proton Experimental. --- src/balatrobot/platforms/proton.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/balatrobot/platforms/proton.py b/src/balatrobot/platforms/proton.py index 7fda913a..a71af395 100644 --- a/src/balatrobot/platforms/proton.py +++ b/src/balatrobot/platforms/proton.py @@ -16,6 +16,8 @@ def _detect_steam_root() -> Path | None: Path.home() / ".local/share/Steam", Path.home() / ".steam/steam", Path("/usr/local/share/Steam"), + Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam", + Path.home() / "snap/steam/common/.local/share/Steam", ] for p in candidates: if (p / "steamapps").is_dir(): @@ -145,6 +147,27 @@ def validate_paths(self, config: Config) -> None: if not lovely.is_file(): errors.append(f"version.dll not found: {lovely}") + # STEAM_COMPAT_* vars are set in build_env() from steam_root. + # If steam_root is undetected and they are absent from the environment, + # Proton will fail to run. + if not steam_root: + missing_compat = [ + v + for v in ( + "STEAM_COMPAT_CLIENT_INSTALL_PATH", + "STEAM_COMPAT_DATA_PATH", + ) + if v not in os.environ + ] + if missing_compat: + errors.append( + "Steam installation not found; cannot set STEAM_COMPAT_* env vars required by Proton.\n" + " Searched: ~/.local/share/Steam, ~/.steam/steam, /usr/local/share/Steam,\n" + " ~/.var/app/com.valvesoftware.Steam/... (Flatpak),\n" + " ~/snap/steam/common/... (Snap)\n" + " Set manually: STEAM_COMPAT_CLIENT_INSTALL_PATH, STEAM_COMPAT_DATA_PATH" + ) + if errors: raise RuntimeError("Path validation failed:\n\n" + "\n\n".join(errors)) From 49ba34519c5397e0f395c037b60a3a57cb85015b Mon Sep 17 00:00:00 2001 From: felipefl142 Date: Sun, 19 Apr 2026 21:59:31 -0300 Subject: [PATCH 06/12] fix(proton): add snap/steam/common/.steam/steam to Steam root candidates The Snap Steam root is ~/.steam/steam symlink under snap/steam/common, not .local/share/Steam. Without this, _detect_steam_root() returns None on Snap installs, STEAM_COMPAT_DATA_PATH is unset, Proton uses a default Wine prefix where version.dll is absent, and lovely-injector fails to inject. --- src/balatrobot/platforms/proton.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/balatrobot/platforms/proton.py b/src/balatrobot/platforms/proton.py index a71af395..f2008f02 100644 --- a/src/balatrobot/platforms/proton.py +++ b/src/balatrobot/platforms/proton.py @@ -18,6 +18,7 @@ def _detect_steam_root() -> Path | None: Path("/usr/local/share/Steam"), Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam", Path.home() / "snap/steam/common/.local/share/Steam", + Path.home() / "snap/steam/common/.steam/steam", ] for p in candidates: if (p / "steamapps").is_dir(): From 7c73e91f6e024eaa16c4dba0fd5b8620a9fffe44 Mon Sep 17 00:00:00 2001 From: felipefl142 Date: Sun, 19 Apr 2026 22:06:56 -0300 Subject: [PATCH 07/12] fix: catch ReadError in health check for slow Proton/Wine startup Wine via Snap Steam accepts TCP connections before the HTTP server is ready, causing ReadError instead of ConnectError. Add ReadError and RemoteProtocolError to the retry loop so health checks retry instead of crashing. --- src/balatrobot/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/balatrobot/manager.py b/src/balatrobot/manager.py index 81bc3f82..1c437dcc 100644 --- a/src/balatrobot/manager.py +++ b/src/balatrobot/manager.py @@ -63,7 +63,7 @@ async def _wait_for_health(self, timeout: float = HEALTH_TIMEOUT) -> None: data = response.json() if "result" in data and data["result"].get("status") == "ok": return - except (httpx.ConnectError, httpx.TimeoutException): + except (httpx.ConnectError, httpx.TimeoutException, httpx.ReadError, httpx.RemoteProtocolError): pass await asyncio.sleep(0.5) From ddf9adcddb06e29990edf57ad6cd83d3cb64cdc8 Mon Sep 17 00:00:00 2001 From: felipefl142 Date: Sun, 19 Apr 2026 22:19:30 -0300 Subject: [PATCH 08/12] feat: add --attach mode for snap/Flatpak Steam When Steam runs inside a sandbox (snap, Flatpak), calling proton directly from outside fails because the Steam Linux Runtime container is not available. --attach skips process management and waits up to 5 minutes for a game launched externally via Steam. Usage: 1. Set Steam launch options: BALATROBOT_PORT=12346 %command% 2. Launch game via Steam 3. balatrobot serve --attach [--port 12346] --- src/balatrobot/cli/serve.py | 26 +++++++++++++++++++++----- src/balatrobot/manager.py | 14 ++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/balatrobot/cli/serve.py b/src/balatrobot/cli/serve.py index e9a280f6..63525afa 100644 --- a/src/balatrobot/cli/serve.py +++ b/src/balatrobot/cli/serve.py @@ -14,6 +14,12 @@ def serve( # fmt: off + attach: Annotated[ + bool, typer.Option( + help="Attach to a game already launched externally (e.g. snap/Flatpak Steam). " + "Skips launching the game and waits up to 5 minutes for it to come up." + ) + ] = False, host: Annotated[ str | None, typer.Option(help="Server hostname (default: 127.0.0.1)") ] = None, @@ -95,14 +101,24 @@ def serve( ) try: - asyncio.run(_serve(config)) + asyncio.run(_serve(config, attach=attach)) except KeyboardInterrupt: typer.echo("\nShutting down server...") -async def _serve(config: Config) -> None: +async def _serve(config: Config, attach: bool = False) -> None: """Async serve implementation.""" - async with BalatroInstance(config) as instance: + if attach: + instance = BalatroInstance(config) + await instance.attach() typer.echo(f"Balatro running on port {instance.port}. Press Ctrl+C to stop.") - while True: - await asyncio.sleep(5) + try: + while True: + await asyncio.sleep(5) + finally: + pass + else: + async with BalatroInstance(config) as instance: + typer.echo(f"Balatro running on port {instance.port}. Press Ctrl+C to stop.") + while True: + await asyncio.sleep(5) diff --git a/src/balatrobot/manager.py b/src/balatrobot/manager.py index 1c437dcc..b961a19b 100644 --- a/src/balatrobot/manager.py +++ b/src/balatrobot/manager.py @@ -12,6 +12,7 @@ from balatrobot.platforms import get_launcher HEALTH_TIMEOUT = 30.0 +ATTACH_TIMEOUT = 300.0 class BalatroInstance: @@ -99,6 +100,19 @@ async def start(self) -> None: print(f"Balatro started (PID: {self._process.pid})") + async def attach(self) -> None: + """Wait for a game already launched externally to become healthy. + + Use this when the game is launched via Steam directly (e.g. snap/Flatpak Steam) + and balatrobot cannot manage the process lifecycle. + """ + print(f"Waiting for Balatro on {self._config.host}:{self._config.port}...") + print("Launch the game via Steam now if you haven't already.") + try: + await self._wait_for_health(timeout=ATTACH_TIMEOUT) + except RuntimeError as e: + raise RuntimeError(str(e)) from e + async def stop(self) -> None: """Stop the Balatro instance.""" if self._process is None: From 3b38441f51820b720c2cce5c61c1c32890a18c0e Mon Sep 17 00:00:00 2001 From: felipefl142 Date: Sun, 19 Apr 2026 22:36:58 -0300 Subject: [PATCH 09/12] feat(proton): auto-detect snap/flatpak/native Steam and launch via URL For sandboxed Steam (snap, flatpak) we cannot invoke proton directly because the Steam Linux Runtime container lives inside the sandbox. Instead: - Auto-detect the Steam package type from the Steam root path. - For native Steam, keep the existing 'proton run Balatro.exe' path. - For snap/flatpak, delegate to the sandboxed Steam client via: snap run steam steam://rungameid/ flatpak run com.valvesoftware.Steam steam://rungameid/ and propagate BALATROBOT_* configuration to the game by writing it to the Wine prefix's user.reg [Environment] section, which Wine exposes to every process launched in the prefix. This keeps the workflow identical across package systems: uvx balatrobot serve --platform proton Also bumps HEALTH_TIMEOUT from 30s to 90s to tolerate slower starts when the sandboxed Steam client has to spin up. --- src/balatrobot/manager.py | 2 +- src/balatrobot/platforms/proton.py | 225 +++++++++++++++++++++-------- 2 files changed, 168 insertions(+), 59 deletions(-) diff --git a/src/balatrobot/manager.py b/src/balatrobot/manager.py index b961a19b..0f032fc8 100644 --- a/src/balatrobot/manager.py +++ b/src/balatrobot/manager.py @@ -11,7 +11,7 @@ from balatrobot.config import Config from balatrobot.platforms import get_launcher -HEALTH_TIMEOUT = 30.0 +HEALTH_TIMEOUT = 90.0 ATTACH_TIMEOUT = 300.0 diff --git a/src/balatrobot/platforms/proton.py b/src/balatrobot/platforms/proton.py index f2008f02..fa95b39b 100644 --- a/src/balatrobot/platforms/proton.py +++ b/src/balatrobot/platforms/proton.py @@ -1,8 +1,24 @@ -"""Steam Proton launcher for Linux.""" +"""Steam Proton launcher for Linux. + +Supports three Steam installation types with automatic detection: + +- native: Steam installed via the package manager / Valve installer. + Launches by invoking the Proton ``run`` command directly. +- snap: Steam installed from the Snap Store (runs in a sandbox). +- flatpak: Steam installed via Flathub (runs in a sandbox). + +For sandboxed Steam installs (snap, flatpak) invoking ``proton`` from the host +does not work because the Steam Linux Runtime container lives inside the +sandbox. Instead, BALATROBOT_* settings are written into the Wine prefix's +``user.reg`` ``[Environment]`` section and the game is launched through the +Steam client itself via the ``steam://rungameid/`` URL handler. +""" import os import re +import time from pathlib import Path +from shutil import which from balatrobot.config import Config from balatrobot.platforms.base import BaseLauncher @@ -26,6 +42,17 @@ def _detect_steam_root() -> Path | None: return None +def _detect_steam_package(steam_root: Path) -> str: + """Return 'snap', 'flatpak', or 'native' based on the Steam root path.""" + s = str(steam_root.resolve() if steam_root.exists() else steam_root) + home = str(Path.home()) + if s.startswith(f"{home}/snap/steam") or "/snap/steam/" in s: + return "snap" + if "com.valvesoftware.Steam" in s: + return "flatpak" + return "native" + + def _proton_sort_key(p: Path) -> tuple: """Sort key that prefers GE-Proton > official Proton > Experimental.""" name = p.parent.name @@ -44,7 +71,6 @@ def _detect_proton_path(steam_root: Path) -> Path | None: """Find the best available Proton executable.""" candidates: list[Path] = [] - # Community Proton builds (GE-Proton, etc.) compat_dirs = [ steam_root / "compatibilitytools.d", Path.home() / ".steam/root/compatibilitytools.d", @@ -56,7 +82,6 @@ def _detect_proton_path(steam_root: Path) -> Path | None: if proton.is_file(): candidates.append(proton) - # Official Proton builds in steamapps/common steamapps_common = steam_root / "steamapps/common" if steamapps_common.is_dir(): for d in steamapps_common.iterdir(): @@ -87,16 +112,84 @@ def _detect_compat_data_path(steam_root: Path) -> Path | None: return p if p.is_dir() else None +def _write_wine_environment(compat_data: Path, env_vars: dict[str, str]) -> None: + """Write BALATROBOT_* env vars into the Wine prefix's ``user.reg`` registry. + + Wine reads ``HKEY_CURRENT_USER\\Environment`` at process launch and exposes + the entries as environment variables to all processes spawned inside the + prefix. This allows Python to propagate configuration to Balatro even when + the game is launched by a sandboxed Steam client we cannot invoke directly. + + Existing non-BALATROBOT_ entries are preserved. + """ + user_reg = compat_data / "pfx" / "user.reg" + if not user_reg.is_file(): + raise RuntimeError( + f"Wine prefix registry not found at {user_reg}. " + "Launch Balatro through Steam once to initialise the prefix." + ) + + content = user_reg.read_text(encoding="utf-8") + + section_re = re.compile( + r"(\[Environment\][^\n]*\n)(.*?)(?=\n\[|\Z)", re.DOTALL + ) + match = section_re.search(content) + + preserved: list[str] = [] + header: str + if match: + header = match.group(1).rstrip("\n") + for line in match.group(2).splitlines(): + stripped = line.strip() + if stripped.startswith('"BALATROBOT_'): + continue + preserved.append(line) + else: + header = f"[Environment] {int(time.time())}" + + new_entries = [f'"{k}"="{v}"' for k, v in env_vars.items()] + + body = "\n".join([*preserved, *new_entries]).rstrip("\n") + "\n" + section = f"{header}\n{body}" + + if match: + content = content[: match.start()] + section + content[match.end() :] + else: + content = content.rstrip() + "\n\n" + section + + user_reg.write_text(content, encoding="utf-8") + + class ProtonLauncher(BaseLauncher): - """Steam Proton launcher for Balatro on Linux.""" + """Steam Proton launcher for Balatro on Linux. + + Selects the launch strategy based on the Steam package type: + - native: invokes ``proton run`` directly + - snap: delegates to ``snap run steam steam://rungameid/`` + - flatpak: delegates to ``flatpak run com.valvesoftware.Steam steam://rungameid/`` + """ def validate_paths(self, config: Config) -> None: - """Validate paths, auto-detect Steam/Proton/Balatro paths if not set.""" + """Validate paths, auto-detect Steam/Proton/Balatro paths where needed.""" errors: list[str] = [] steam_root = _detect_steam_root() - # --- Balatro path --- - if config.balatro_path is None and steam_root: + if not steam_root: + errors.append( + "Steam installation not found.\n" + " Searched: ~/.local/share/Steam, ~/.steam/steam, /usr/local/share/Steam,\n" + " ~/.var/app/com.valvesoftware.Steam/... (Flatpak),\n" + " ~/snap/steam/common/... (Snap)" + ) + raise RuntimeError("Path validation failed:\n\n" + "\n\n".join(errors)) + + package = _detect_steam_package(steam_root) + config._steam_package = package # type: ignore[attr-defined] + config._steam_root = str(steam_root) # type: ignore[attr-defined] + + # Balatro game dir (needed for lovely-injector validation) + if config.balatro_path is None: detected = _detect_balatro_path(steam_root) if detected: config.balatro_path = str(detected) @@ -104,8 +197,7 @@ def validate_paths(self, config: Config) -> None: if config.balatro_path is None: errors.append( "Balatro game directory is required.\n" - " Set via: --balatro-path or BALATROBOT_BALATRO_PATH\n" - " Expected: ~/.local/share/Steam/steamapps/common/Balatro" + " Set via: --balatro-path or BALATROBOT_BALATRO_PATH" ) else: balatro = Path(config.balatro_path) @@ -114,24 +206,7 @@ def validate_paths(self, config: Config) -> None: elif not (balatro / "Balatro.exe").is_file(): errors.append(f"Balatro.exe not found in: {balatro}") - # --- Proton path (stored in love_path) --- - if config.love_path is None and steam_root: - detected = _detect_proton_path(steam_root) - if detected: - config.love_path = str(detected) - - if config.love_path is None: - errors.append( - "Proton executable is required.\n" - " Set via: --love-path or BALATROBOT_LOVE_PATH\n" - " Expected: path to a 'proton' script inside your Proton installation" - ) - else: - proton = Path(config.love_path) - if not proton.is_file(): - errors.append(f"Proton executable not found: {proton}") - - # --- lovely-injector (version.dll) --- + # lovely-injector version.dll if config.lovely_path is None and config.balatro_path: detected = _detect_lovely_path(Path(config.balatro_path)) if detected: @@ -141,61 +216,95 @@ def validate_paths(self, config: Config) -> None: errors.append( "lovely-injector version.dll is required.\n" " Set via: --lovely-path or BALATROBOT_LOVELY_PATH\n" - " Expected: ~/.local/share/Steam/steamapps/common/Balatro/version.dll" + " Place version.dll in the Balatro game directory." ) else: lovely = Path(config.lovely_path) if not lovely.is_file(): errors.append(f"version.dll not found: {lovely}") - # STEAM_COMPAT_* vars are set in build_env() from steam_root. - # If steam_root is undetected and they are absent from the environment, - # Proton will fail to run. - if not steam_root: - missing_compat = [ - v - for v in ( - "STEAM_COMPAT_CLIENT_INSTALL_PATH", - "STEAM_COMPAT_DATA_PATH", + # Proton is only required for the native launch strategy + if package == "native": + if config.love_path is None: + detected = _detect_proton_path(steam_root) + if detected: + config.love_path = str(detected) + + if config.love_path is None: + errors.append( + "Proton executable is required for native Steam.\n" + " Set via: --love-path or BALATROBOT_LOVE_PATH" ) - if v not in os.environ - ] - if missing_compat: + else: + proton = Path(config.love_path) + if not proton.is_file(): + errors.append(f"Proton executable not found: {proton}") + else: + # Sandboxed Steam: verify the CLI tool is available on the host + tool = "snap" if package == "snap" else "flatpak" + if which(tool) is None: errors.append( - "Steam installation not found; cannot set STEAM_COMPAT_* env vars required by Proton.\n" - " Searched: ~/.local/share/Steam, ~/.steam/steam, /usr/local/share/Steam,\n" - " ~/.var/app/com.valvesoftware.Steam/... (Flatpak),\n" - " ~/snap/steam/common/... (Snap)\n" - " Set manually: STEAM_COMPAT_CLIENT_INSTALL_PATH, STEAM_COMPAT_DATA_PATH" + f"{tool!r} executable not found on PATH but Steam is installed as a {package}.\n" + f" Install {tool} or run balatrobot outside the snap sandbox." + ) + + compat_data = _detect_compat_data_path(steam_root) + if compat_data is None: + errors.append( + "Balatro Wine prefix not found. Launch Balatro through Steam at " + "least once to create the prefix." ) if errors: raise RuntimeError("Path validation failed:\n\n" + "\n\n".join(errors)) + print(f"Detected Steam package: {package}") + def build_env(self, config: Config) -> dict[str, str]: - """Build environment for Proton, including Wine DLL override for lovely-injector.""" - assert config.love_path is not None + """Build the subprocess environment. + + For native Steam, sets the Proton-required STEAM_COMPAT_* variables and + appends BALATROBOT_* variables. For sandboxed Steam, BALATROBOT_* + variables are instead written to the Wine prefix registry so they reach + the game process launched by the sandboxed Steam client. + """ assert config.balatro_path is not None env = os.environ.copy() + package: str = getattr(config, "_steam_package", "native") + steam_root = Path(getattr(config, "_steam_root", _detect_steam_root() or "")) - # lovely-injector uses a version.dll DLL hijack — tell Wine to load it - env["WINEDLLOVERRIDES"] = "version=n,b" - - steam_root = _detect_steam_root() - if steam_root: + if package == "native": + env["WINEDLLOVERRIDES"] = "version=n,b" env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = str(steam_root) compat_data = _detect_compat_data_path(steam_root) if compat_data: env["STEAM_COMPAT_DATA_PATH"] = str(compat_data) + env.update(config.to_env()) + return env - env.update(config.to_env()) + # Sandboxed Steam: write BALATROBOT_* to the Wine prefix registry. + compat_data = _detect_compat_data_path(steam_root) + assert compat_data is not None # validated in validate_paths + _write_wine_environment(compat_data, config.to_env()) return env def build_cmd(self, config: Config) -> list[str]: - """Build Proton launch command: proton run Balatro.exe.""" - assert config.love_path is not None - assert config.balatro_path is not None + """Build the launch command appropriate for the detected Steam package.""" + package: str = getattr(config, "_steam_package", "native") + + if package == "native": + assert config.love_path is not None + assert config.balatro_path is not None + balatro_exe = str(Path(config.balatro_path) / "Balatro.exe") + return [config.love_path, "run", balatro_exe] + + steam_url = f"steam://rungameid/{BALATRO_APP_ID}" + if package == "snap": + return ["snap", "run", "steam", steam_url] + if package == "flatpak": + return ["flatpak", "run", "com.valvesoftware.Steam", steam_url] + + raise RuntimeError(f"Unknown Steam package type: {package}") + - balatro_exe = str(Path(config.balatro_path) / "Balatro.exe") - return [config.love_path, "run", balatro_exe] From 7a8c09eb6da4bd960f8b9b942b02dd1cf2bdb2cf Mon Sep 17 00:00:00 2001 From: felipefl142 Date: Sun, 19 Apr 2026 23:12:35 -0300 Subject: [PATCH 10/12] fix(proton): map 'linux' platform to ProtonLauncher Auto-detect via platform.system().lower() returns 'linux', which previously fell through to RuntimeError. Map it to ProtonLauncher alongside explicit 'proton'. --- src/balatrobot/platforms/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/balatrobot/platforms/__init__.py b/src/balatrobot/platforms/__init__.py index 5631f6fd..3e3a9ea2 100644 --- a/src/balatrobot/platforms/__init__.py +++ b/src/balatrobot/platforms/__init__.py @@ -6,7 +6,7 @@ if TYPE_CHECKING: from balatrobot.platforms.base import BaseLauncher -VALID_PLATFORMS = frozenset({"darwin", "proton", "windows", "native"}) +VALID_PLATFORMS = frozenset({"darwin", "linux", "proton", "windows", "native"}) def get_launcher(platform: str | None = None) -> "BaseLauncher": @@ -39,7 +39,7 @@ def get_launcher(platform: str | None = None) -> "BaseLauncher": from balatrobot.platforms.macos import MacOSLauncher return MacOSLauncher() - case "proton": + case "linux" | "proton": from balatrobot.platforms.proton import ProtonLauncher return ProtonLauncher() From 73d4f1e0921e8b56a8e4cf567f59c9c2c5898efa Mon Sep 17 00:00:00 2001 From: felipefl142 Date: Mon, 20 Apr 2026 00:15:26 -0300 Subject: [PATCH 11/12] style: apply ruff format --- src/balatrobot/cli/serve.py | 11 +++++++---- src/balatrobot/manager.py | 7 ++++++- src/balatrobot/platforms/proton.py | 6 +----- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/balatrobot/cli/serve.py b/src/balatrobot/cli/serve.py index 63525afa..88d080fd 100644 --- a/src/balatrobot/cli/serve.py +++ b/src/balatrobot/cli/serve.py @@ -15,10 +15,11 @@ def serve( # fmt: off attach: Annotated[ - bool, typer.Option( + bool, + typer.Option( help="Attach to a game already launched externally (e.g. snap/Flatpak Steam). " - "Skips launching the game and waits up to 5 minutes for it to come up." - ) + "Skips launching the game and waits up to 5 minutes for it to come up." + ), ] = False, host: Annotated[ str | None, typer.Option(help="Server hostname (default: 127.0.0.1)") @@ -119,6 +120,8 @@ async def _serve(config: Config, attach: bool = False) -> None: pass else: async with BalatroInstance(config) as instance: - typer.echo(f"Balatro running on port {instance.port}. Press Ctrl+C to stop.") + typer.echo( + f"Balatro running on port {instance.port}. Press Ctrl+C to stop." + ) while True: await asyncio.sleep(5) diff --git a/src/balatrobot/manager.py b/src/balatrobot/manager.py index 0f032fc8..72f5fccd 100644 --- a/src/balatrobot/manager.py +++ b/src/balatrobot/manager.py @@ -64,7 +64,12 @@ async def _wait_for_health(self, timeout: float = HEALTH_TIMEOUT) -> None: data = response.json() if "result" in data and data["result"].get("status") == "ok": return - except (httpx.ConnectError, httpx.TimeoutException, httpx.ReadError, httpx.RemoteProtocolError): + except ( + httpx.ConnectError, + httpx.TimeoutException, + httpx.ReadError, + httpx.RemoteProtocolError, + ): pass await asyncio.sleep(0.5) diff --git a/src/balatrobot/platforms/proton.py b/src/balatrobot/platforms/proton.py index fa95b39b..9875ed82 100644 --- a/src/balatrobot/platforms/proton.py +++ b/src/balatrobot/platforms/proton.py @@ -131,9 +131,7 @@ def _write_wine_environment(compat_data: Path, env_vars: dict[str, str]) -> None content = user_reg.read_text(encoding="utf-8") - section_re = re.compile( - r"(\[Environment\][^\n]*\n)(.*?)(?=\n\[|\Z)", re.DOTALL - ) + section_re = re.compile(r"(\[Environment\][^\n]*\n)(.*?)(?=\n\[|\Z)", re.DOTALL) match = section_re.search(content) preserved: list[str] = [] @@ -306,5 +304,3 @@ def build_cmd(self, config: Config) -> list[str]: return ["flatpak", "run", "com.valvesoftware.Steam", steam_url] raise RuntimeError(f"Unknown Steam package type: {package}") - - From 09d6459c5ca6ce6f78caf42819610b035d46e08a Mon Sep 17 00:00:00 2001 From: felipefl142 Date: Mon, 20 Apr 2026 00:17:20 -0300 Subject: [PATCH 12/12] feat(proton): prefer Proton Experimental, then GE-Proton, then official --- src/balatrobot/platforms/proton.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/balatrobot/platforms/proton.py b/src/balatrobot/platforms/proton.py index 9875ed82..d6449798 100644 --- a/src/balatrobot/platforms/proton.py +++ b/src/balatrobot/platforms/proton.py @@ -54,16 +54,16 @@ def _detect_steam_package(steam_root: Path) -> str: def _proton_sort_key(p: Path) -> tuple: - """Sort key that prefers GE-Proton > official Proton > Experimental.""" + """Sort key that prefers Proton Experimental > GE-Proton > official Proton.""" name = p.parent.name + if "Experimental" in name: + return (0, 0, 0) m = re.match(r"GE-Proton(\d+)-(\d+)", name) if m: - return (0, -int(m.group(1)), -int(m.group(2))) + return (1, -int(m.group(1)), -int(m.group(2))) m = re.match(r"Proton (\d+)\.(\d+)", name) if m: - return (1, -int(m.group(1)), -int(m.group(2))) - if "Experimental" in name: - return (2, 0, 0) + return (2, -int(m.group(1)), -int(m.group(2))) return (3, 0, 0)