-
Notifications
You must be signed in to change notification settings - Fork 4
feat(platforms): add CLI support for Linux through Proton #134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
d46781e
feat(config): add steam_path configuration option
S1M0N38 a1a04bd
feat(platforms): implement Linux Proton launcher
S1M0N38 08d132b
feat(platforms): register LinuxLauncher for linux platform
S1M0N38 78d0b21
test(platforms): add tests for LinuxLauncher
S1M0N38 100c71a
docs(cli): add Linux (Proton) platform documentation
S1M0N38 0d96161
fix(platforms): use tuple comparison for Proton version sorting
S1M0N38 ede933b
test(platforms): add test for missing Proton prefix
S1M0N38 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| """Linux Proton platform launcher for Balatro via Steam.""" | ||
|
|
||
| import os | ||
| import platform | ||
| import re | ||
| from pathlib import Path | ||
|
|
||
| from balatrobot.config import Config | ||
| from balatrobot.platforms.base import BaseLauncher | ||
|
|
||
| # Balatro Steam App ID | ||
| BALATRO_APP_ID = "2379780" | ||
|
|
||
| # Known Steam installation paths (in priority order) | ||
| STEAM_PATH_CANDIDATES = [ | ||
| Path.home() / ".local/share/Steam", | ||
| Path.home() / ".steam/steam", | ||
| Path.home() / "snap/steam/common/.local/share/Steam", | ||
| Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam", | ||
| ] | ||
|
|
||
|
|
||
| def _detect_steam_path() -> Path | None: | ||
| """Detect Steam installation from known locations.""" | ||
| for candidate in STEAM_PATH_CANDIDATES: | ||
| if candidate.is_dir() and (candidate / "steamapps").is_dir(): | ||
| return candidate | ||
| return None | ||
|
|
||
|
|
||
| def _detect_proton_path(steam_path: Path) -> Path | None: | ||
| """Detect Proton runtime in Steam installation. | ||
| Prefers Proton - Experimental, then falls back to latest versioned Proton. | ||
| """ | ||
| common = steam_path / "steamapps/common" | ||
| if not common.is_dir(): | ||
| return None | ||
|
|
||
| # Prefer Proton - Experimental | ||
| experimental = common / "Proton - Experimental" / "proton" | ||
| if experimental.is_file(): | ||
| return experimental | ||
|
|
||
| # Find versioned Proton installations (e.g., "Proton 9.0", "Proton 8.0") | ||
| proton_dirs = [] | ||
| for entry in common.iterdir(): | ||
| if entry.is_dir() and entry.name.startswith("Proton "): | ||
| match = re.match(r"Proton (\d+(?:\.\d+)?)", entry.name) | ||
| if match: | ||
| version = float(match.group(1)) | ||
| proton_exec = entry / "proton" | ||
| if proton_exec.is_file(): | ||
| proton_dirs.append((version, proton_exec)) | ||
|
|
||
| if proton_dirs: | ||
| # Sort by version descending, return the latest | ||
| proton_dirs.sort(key=lambda x: x[0], reverse=True) | ||
| return proton_dirs[0][1] | ||
|
|
||
| return None | ||
|
|
||
|
|
||
| class LinuxLauncher(BaseLauncher): | ||
| """Linux launcher for Balatro via Steam/Proton. | ||
| This launcher is designed for: | ||
| - Linux desktop with Steam and Proton installed | ||
| - Running Windows version of Balatro through Proton | ||
| Requirements: | ||
| - Linux operating system | ||
| - Steam installed with Proton runtime | ||
| - Balatro installed via Steam | ||
| - Lovely injector (version.dll) in Balatro directory | ||
| """ | ||
|
|
||
| def validate_paths(self, config: Config) -> None: | ||
| """Validate and auto-detect paths for Linux Proton launcher.""" | ||
| if platform.system().lower() != "linux": | ||
| raise RuntimeError("Linux Proton launcher is only supported on Linux") | ||
|
|
||
| errors: list[str] = [] | ||
|
|
||
| # Steam path (auto-detect or use config) | ||
| steam_path: Path | None = None | ||
| if config.steam_path: | ||
| steam_path = Path(config.steam_path) | ||
| if not steam_path.is_dir(): | ||
| errors.append(f"Steam directory not found: {steam_path}") | ||
| elif not (steam_path / "steamapps").is_dir(): | ||
| errors.append(f"Invalid Steam directory (no steamapps): {steam_path}") | ||
| else: | ||
| steam_path = _detect_steam_path() | ||
| if steam_path: | ||
| config.steam_path = str(steam_path) | ||
| else: | ||
| errors.append( | ||
| "Steam installation not found.\n" | ||
| " Set via: --steam-path or BALATROBOT_STEAM_PATH\n" | ||
| " Tried: " + ", ".join(str(p) for p in STEAM_PATH_CANDIDATES) | ||
| ) | ||
|
|
||
| if not steam_path or not steam_path.is_dir(): | ||
| raise RuntimeError("Path validation failed:\n\n" + "\n\n".join(errors)) | ||
|
|
||
| # Balatro executable (love_path stores the exe path for consistency) | ||
| if config.love_path is None: | ||
| balatro_exe = steam_path / "steamapps/common/Balatro/Balatro.exe" | ||
| if balatro_exe.is_file(): | ||
| config.love_path = str(balatro_exe) | ||
| else: | ||
| errors.append( | ||
| "Balatro.exe not found.\n" | ||
| f" Expected: {balatro_exe}\n" | ||
| " Make sure Balatro is installed via Steam." | ||
| ) | ||
| else: | ||
| if not Path(config.love_path).is_file(): | ||
| errors.append(f"Balatro executable not found: {config.love_path}") | ||
|
|
||
| # Lovely injector (version.dll) | ||
| if config.lovely_path is None: | ||
| version_dll = steam_path / "steamapps/common/Balatro/version.dll" | ||
| if version_dll.is_file(): | ||
| config.lovely_path = str(version_dll) | ||
| else: | ||
| errors.append( | ||
| "Lovely injector (version.dll) not found.\n" | ||
| f" Expected: {version_dll}\n" | ||
| " Install lovely-injector for Windows into the Balatro directory." | ||
| ) | ||
| else: | ||
| if not Path(config.lovely_path).is_file(): | ||
| errors.append(f"Lovely injector not found: {config.lovely_path}") | ||
|
|
||
| # Proton runtime (balatro_path stores proton path for this launcher) | ||
| if config.balatro_path is None: | ||
| proton_path = _detect_proton_path(steam_path) | ||
| if proton_path: | ||
| config.balatro_path = str(proton_path) | ||
| else: | ||
| errors.append( | ||
| "Proton runtime not found.\n" | ||
| " Install 'Proton - Experimental' or a versioned Proton via Steam.\n" | ||
| " Or set via: --balatro-path or BALATROBOT_BALATRO_PATH" | ||
| ) | ||
| else: | ||
| if not Path(config.balatro_path).is_file(): | ||
| errors.append(f"Proton executable not found: {config.balatro_path}") | ||
|
|
||
| # Proton prefix must exist (created by first game launch) | ||
| compat_data = steam_path / f"steamapps/compatdata/{BALATRO_APP_ID}" | ||
| if not compat_data.is_dir(): | ||
| errors.append( | ||
| f"Proton prefix not found: {compat_data}\n" | ||
| " Run Balatro at least once through Steam to create the prefix." | ||
| ) | ||
|
|
||
| if errors: | ||
| raise RuntimeError("Path validation failed:\n\n" + "\n\n".join(errors)) | ||
|
|
||
| def build_env(self, config: Config) -> dict[str, str]: | ||
| """Build environment with Proton compatibility variables.""" | ||
| assert config.steam_path is not None | ||
| steam_path = Path(config.steam_path) | ||
|
|
||
| env = os.environ.copy() | ||
|
|
||
| # Proton environment variables | ||
| env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = str(steam_path) | ||
| env["STEAM_COMPAT_DATA_PATH"] = str( | ||
| steam_path / f"steamapps/compatdata/{BALATRO_APP_ID}" | ||
| ) | ||
|
|
||
| # Force native version.dll loading for lovely injection | ||
| env["WINEDLLOVERRIDES"] = "version=n,b" | ||
|
|
||
| # Add config environment variables | ||
| env.update(config.to_env()) | ||
|
|
||
| return env | ||
|
|
||
| def build_cmd(self, config: Config) -> list[str]: | ||
| """Build Proton launch command.""" | ||
| assert config.balatro_path is not None # Proton path | ||
| assert config.love_path is not None # Balatro.exe path | ||
|
|
||
| return [config.balatro_path, "run", config.love_path] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
|
|
||
| from balatrobot.config import Config | ||
| from balatrobot.platforms import VALID_PLATFORMS, get_launcher | ||
| from balatrobot.platforms.linux import LinuxLauncher | ||
| from balatrobot.platforms.macos import MacOSLauncher | ||
| from balatrobot.platforms.native import NativeLauncher | ||
| from balatrobot.platforms.windows import WindowsLauncher | ||
|
|
@@ -38,10 +39,10 @@ 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_linux_returns_linux_launcher(self): | ||
| """'linux' returns LinuxLauncher.""" | ||
| launcher = get_launcher("linux") | ||
| assert isinstance(launcher, LinuxLauncher) | ||
|
|
||
| def test_valid_platforms_constant(self): | ||
| """VALID_PLATFORMS contains expected values.""" | ||
|
|
@@ -176,3 +177,51 @@ def test_build_cmd(self, tmp_path): | |
| cmd = launcher.build_cmd(config) | ||
|
|
||
| assert cmd == [r"C:\path\to\Balatro.exe"] | ||
|
|
||
|
|
||
| @pytest.mark.skipif(not IS_LINUX, reason="Linux only") | ||
| class TestLinuxLauncher: | ||
| """Tests for LinuxLauncher (Linux only).""" | ||
|
|
||
| def test_validate_paths_missing_steam(self, tmp_path): | ||
| """Raises RuntimeError when Steam installation not found.""" | ||
| launcher = LinuxLauncher() | ||
| config = Config(steam_path=str(tmp_path / "nonexistent")) | ||
|
|
||
| with pytest.raises(RuntimeError, match="Steam directory not found"): | ||
| launcher.validate_paths(config) | ||
|
|
||
| def test_validate_paths_invalid_steam(self, tmp_path): | ||
| """Raises RuntimeError when Steam directory has no steamapps.""" | ||
| # Create a fake Steam directory without steamapps | ||
| steam_path = tmp_path / "Steam" | ||
| steam_path.mkdir() | ||
|
|
||
| launcher = LinuxLauncher() | ||
| config = Config(steam_path=str(steam_path)) | ||
|
|
||
| with pytest.raises(RuntimeError, match="Invalid Steam directory"): | ||
| launcher.validate_paths(config) | ||
|
|
||
| def test_build_env_includes_proton_vars(self, tmp_path): | ||
| """build_env includes Proton compatibility environment variables.""" | ||
| launcher = LinuxLauncher() | ||
| config = Config(steam_path="/path/to/Steam") | ||
|
|
||
| env = launcher.build_env(config) | ||
|
|
||
| assert env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] == "/path/to/Steam" | ||
| assert "compatdata/2379780" in env["STEAM_COMPAT_DATA_PATH"] | ||
| assert env["WINEDLLOVERRIDES"] == "version=n,b" | ||
|
|
||
| def test_build_cmd(self, tmp_path): | ||
| """build_cmd returns proton run command.""" | ||
| launcher = LinuxLauncher() | ||
| config = Config( | ||
| balatro_path="/path/to/proton", | ||
| love_path="/path/to/Balatro.exe", | ||
| ) | ||
|
|
||
| cmd = launcher.build_cmd(config) | ||
|
|
||
| assert cmd == ["/path/to/proton", "run", "/path/to/Balatro.exe"] | ||
|
Comment on lines
+182
to
+227
|
||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Converting version strings to float for comparison can lead to incorrect sorting for versions with multiple decimal points. For example, "Proton 8.0.5" would be captured as "8.0" and converted to 8.0, while "Proton 8.1" would be 8.1. However, the regex
r"Proton (\d+(?:\.\d+)?)"only captures one optional decimal component, so this limitation is consistent. Consider documenting this limitation or using a more robust version comparison approach if multi-decimal versions are expected in the future.