Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/balatrobot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/balatrobot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/balatrobot/platforms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
189 changes: 189 additions & 0 deletions src/balatrobot/platforms/linux.py
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))
Copy link

Copilot AI Jan 1, 2026

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.

Suggested change
# 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))
# Find versioned Proton installations (e.g., "Proton 9.0", "Proton 8.0", "Proton 8.0.5")
proton_dirs = []
for entry in common.iterdir():
if entry.is_dir() and entry.name.startswith("Proton "):
# Capture one or more dot-separated numeric components after "Proton "
match = re.match(r"Proton (\d+(?:\.\d+)*)", entry.name)
if match:
version_str = match.group(1)
# Parse version into a tuple of integers for correct numeric sorting
version = tuple(int(part) for part in version_str.split("."))

Copilot uses AI. Check for mistakes.
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]
57 changes: 53 additions & 4 deletions tests/cli/test_platforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test coverage for LinuxLauncher is incomplete compared to other platform launchers. Consider adding tests for:

  • Missing Balatro.exe (similar to test_validate_paths_missing_balatro_exe in WindowsLauncher)
  • Missing version.dll (similar to test_validate_paths_missing_version_dll in WindowsLauncher)
  • Missing Proton runtime
  • Missing Proton prefix (compatdata directory)
  • Platform check validation (testing the RuntimeError when not on Linux)

Copilot uses AI. Check for mistakes.