diff --git a/docs/cli.md b/docs/cli.md index 4e1c1821..972f1917 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -27,6 +27,7 @@ All options can be set via CLI flags or environment variables. CLI flags overrid | `--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) | +| `--steam-path STEAM_PATH` | `BALATROBOT_STEAM_PATH` | auto-detected | Path to Steam installation (Linux Proton) | | `--platform PLATFORM` | `BALATROBOT_PLATFORM` | auto-detected | Platform: darwin, linux, windows, native | | `--logs-path LOGS_PATH` | `BALATROBOT_LOGS_PATH` | `logs` | Directory for log files | | `-h, --help` | - | - | Show help message and exit | @@ -147,6 +148,42 @@ balatrobot --fast balatrobot --love-path "/path/to/love" --lovely-path "/path/to/liblovely.dylib" ``` +### Linux Platform (Proton) + +The `linux` platform launches Balatro through Steam's Proton compatibility layer. The CLI auto-detects Steam and Proton installation paths: + +**Auto-Detected Paths:** + +- `BALATROBOT_STEAM_PATH`: Tries these locations in order: + - `~/.local/share/Steam` (standard) + - `~/.steam/steam` (symlink) + - `~/snap/steam/common/.local/share/Steam` (Snap) + - `~/.var/app/com.valvesoftware.Steam/.local/share/Steam` (Flatpak) +- `BALATROBOT_LOVE_PATH`: `{steam_path}/steamapps/common/Balatro/Balatro.exe` +- `BALATROBOT_LOVELY_PATH`: `{steam_path}/steamapps/common/Balatro/version.dll` +- Proton runtime: Auto-detected (prefers `Proton - Experimental`, falls back to latest versioned) + +**Requirements:** + +- Balatro installed via Steam +- Proton installed via Steam (Proton - Experimental recommended) +- [Lovely Injector](https://github.com/ethangreen-dev/lovely-injector) `version.dll` (Windows version) placed in the Balatro game directory +- Run Balatro at least once through Steam to create the Proton prefix +- Mods directory: `~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods` + +**Launch:** + +```bash +# Auto-detects Steam and Proton paths +balatrobot --fast + +# Or specify Steam path explicitly +balatrobot --steam-path ~/.local/share/Steam --fast + +# Via environment variable +BALATROBOT_STEAM_PATH=~/.local/share/Steam balatrobot --fast +``` + ### 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/src/balatrobot/cli.py b/src/balatrobot/cli.py index f1a829b2..209d90eb 100644 --- a/src/balatrobot/cli.py +++ b/src/balatrobot/cli.py @@ -29,6 +29,7 @@ def create_parser() -> argparse.ArgumentParser: parser.add_argument("--balatro-path", help="Path to Balatro executable") parser.add_argument("--lovely-path", help="Path to lovely library") parser.add_argument("--love-path", help="Path to LOVE executable") + parser.add_argument("--steam-path", help="Path to Steam installation (Linux Proton)") parser.add_argument("--platform", choices=["darwin", "linux", "windows", "native"]) # fmt: on diff --git a/src/balatrobot/config.py b/src/balatrobot/config.py index f823b738..11180ccd 100644 --- a/src/balatrobot/config.py +++ b/src/balatrobot/config.py @@ -17,6 +17,7 @@ "balatro_path": "BALATROBOT_BALATRO_PATH", "lovely_path": "BALATROBOT_LOVELY_PATH", "love_path": "BALATROBOT_LOVE_PATH", + "steam_path": "BALATROBOT_STEAM_PATH", "platform": "BALATROBOT_PLATFORM", "logs_path": "BALATROBOT_LOGS_PATH", } @@ -56,6 +57,7 @@ class Config: balatro_path: str | None = None lovely_path: str | None = None love_path: str | None = None + steam_path: str | None = None # Instance platform: str | None = None diff --git a/src/balatrobot/platforms/__init__.py b/src/balatrobot/platforms/__init__.py index aa8a560c..15d75d72 100644 --- a/src/balatrobot/platforms/__init__.py +++ b/src/balatrobot/platforms/__init__.py @@ -40,7 +40,9 @@ def get_launcher(platform: str | None = None) -> "BaseLauncher": return MacOSLauncher() case "linux": - raise NotImplementedError("Linux launcher not yet implemented") + from balatrobot.platforms.linux import LinuxLauncher + + return LinuxLauncher() case "windows": from balatrobot.platforms.windows import WindowsLauncher diff --git a/src/balatrobot/platforms/linux.py b/src/balatrobot/platforms/linux.py new file mode 100644 index 00000000..905b479e --- /dev/null +++ b/src/balatrobot/platforms/linux.py @@ -0,0 +1,191 @@ +"""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.5") + proton_dirs: list[tuple[tuple[int, ...], Path]] = [] + 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: + # Parse version as tuple for correct semantic version comparison + # e.g., "8.10" -> (8, 10) which correctly compares > (8, 9) + version = tuple(int(part) for part in match.group(1).split(".")) + 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] diff --git a/tests/cli/test_platforms.py b/tests/cli/test_platforms.py index 5ead2781..da06115c 100644 --- a/tests/cli/test_platforms.py +++ b/tests/cli/test_platforms.py @@ -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,76 @@ 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"] + + def test_validate_paths_missing_proton_prefix(self, tmp_path): + """Raises RuntimeError when Proton prefix (compatdata) not found.""" + # Create valid Steam structure but without compatdata + steam_path = tmp_path / "Steam" + steamapps = steam_path / "steamapps" + steamapps.mkdir(parents=True) + + # Create Balatro and Proton directories + balatro_dir = steamapps / "common/Balatro" + balatro_dir.mkdir(parents=True) + (balatro_dir / "Balatro.exe").touch() + (balatro_dir / "version.dll").touch() + + proton_dir = steamapps / "common/Proton - Experimental" + proton_dir.mkdir(parents=True) + (proton_dir / "proton").touch() + + # No compatdata/2379780 directory + + launcher = LinuxLauncher() + config = Config(steam_path=str(steam_path)) + + with pytest.raises(RuntimeError, match="Proton prefix not found"): + launcher.validate_paths(config)