From eb2b9d5c7507ced718ca70372c86c7c5ac672cae Mon Sep 17 00:00:00 2001 From: KyleBastien Date: Tue, 23 Dec 2025 22:11:43 -0800 Subject: [PATCH 1/2] Add Windows support to CLI --- docs/cli.md | 56 ++++++++++++++++++++++++++- docs/contributing.md | 25 ++++++++++-- src/balatrobot/platforms/__init__.py | 4 +- src/balatrobot/platforms/windows.py | 37 ++++++++++++++++++ tests/cli/test_platforms.py | 57 ++++++++++++++++++++++++++-- 5 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 src/balatrobot/platforms/windows.py diff --git a/docs/cli.md b/docs/cli.md index aba4afc..8a6d165 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -93,7 +93,61 @@ The CLI automatically: - Sets up the correct environment variables - Gracefully shuts down on Ctrl+C -## Native Platform (Linux Only) +## Platform-Specific Details + +### Windows Platform + +The `windows` platform launches Balatro via Steam on Windows. The CLI auto-detects the Steam installation paths: + +**Auto-Detected Paths:** + +- `BALATROBOT_LOVE_PATH`: `C:\Program Files (x86)\Steam\steamapps\common\Balatro\Balatro.exe` +- `BALATROBOT_LOVELY_PATH`: `C:\Program Files (x86)\Steam\steamapps\common\Balatro\version.dll` + +**Requirements:** + +- Balatro installed via Steam +- [Lovely Injector](https://github.com/ethangreen-dev/lovely-injector) `version.dll` placed in the Balatro game directory +- Mods directory: `%AppData%\Balatro\Mods` + +**Launch:** + +```powershell +# Auto-detects paths +balatrobot --fast + +# Or specify custom paths +balatrobot --love-path "C:\Custom\Path\Balatro.exe" --lovely-path "C:\Custom\Path\version.dll" +``` + +### macOS Platform + +The `darwin` platform launches Balatro via Steam on macOS. The CLI auto-detects the Steam installation paths: + +**Auto-Detected Paths:** + +- `BALATROBOT_LOVE_PATH`: `~/Library/Application Support/Steam/steamapps/common/Balatro/Balatro.app/Contents/MacOS/love` +- `BALATROBOT_LOVELY_PATH`: `~/Library/Application Support/Steam/steamapps/common/Balatro/liblovely.dylib` + +**Requirements:** + +- Balatro installed via Steam +- [Lovely Injector](https://github.com/ethangreen-dev/lovely-injector) `liblovely.dylib` and `run_lovely_macos.sh` in the Balatro game directory +- Mods directory: `~/Library/Application Support/Balatro/Mods` + +**Note:** You cannot run the game through Steam on macOS due to a Steam client bug. The CLI handles this by directly executing the LOVE runtime with proper environment variables. + +**Launch:** + +```bash +# Auto-detects paths +balatrobot --fast + +# Or specify custom paths +balatrobot --love-path "/path/to/love" --lovely-path "/path/to/liblovely.dylib" +``` + +### 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 a4e71cd..8933d4b 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -2,13 +2,14 @@ Guide for contributing to BalatroBot development. -!!! warning "Help Needed: Windows & Linux (Proton) Support" +!!! warning "Help Needed: Linux (Proton) Support" - We currently lack CLI support for **Windows** and **Linux (Proton)**. Contributions to implement these platforms are highly welcome! + We currently lack CLI support for **Linux (Proton)**. Contributions to implement this platform are highly welcome! 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` ## Prerequisites @@ -49,7 +50,23 @@ ln -s "$(pwd)" ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/use New-Item -ItemType SymbolicLink -Path "$env:APPDATA\Balatro\Mods\balatrobot" -Target (Get-Location) ``` -### 3. Launch Balatro +### 3. Activate Virtual Environment + +Activate the virtual environment to use the `balatrobot` command: + +**macOS/Linux:** + +```bash +source .venv/bin/activate +``` + +**Windows (PowerShell):** + +```powershell +.venv\Scripts\Activate.ps1 +``` + +### 4. Launch Balatro Start with debug and fast mode for development: @@ -59,7 +76,7 @@ balatrobot --debug --fast For detailed CLI options, see the [CLI Reference](cli.md). -### 4. Running Tests +### 5. Running Tests Tests use Python + pytest to communicate with the Lua API. diff --git a/src/balatrobot/platforms/__init__.py b/src/balatrobot/platforms/__init__.py index 4ca5925..aa8a560 100644 --- a/src/balatrobot/platforms/__init__.py +++ b/src/balatrobot/platforms/__init__.py @@ -42,7 +42,9 @@ def get_launcher(platform: str | None = None) -> "BaseLauncher": case "linux": raise NotImplementedError("Linux launcher not yet implemented") case "windows": - raise NotImplementedError("Windows launcher not yet implemented") + from balatrobot.platforms.windows import WindowsLauncher + + return WindowsLauncher() case "native": from balatrobot.platforms.native import NativeLauncher diff --git a/src/balatrobot/platforms/windows.py b/src/balatrobot/platforms/windows.py new file mode 100644 index 0000000..adea304 --- /dev/null +++ b/src/balatrobot/platforms/windows.py @@ -0,0 +1,37 @@ +"""Windows platform launcher.""" + +import os +from pathlib import Path + +from balatrobot.config import Config +from balatrobot.platforms.base import BaseLauncher + + +class WindowsLauncher(BaseLauncher): + """Windows-specific Balatro launcher via Steam.""" + + def validate_paths(self, config: Config) -> None: + """Validate paths, apply Windows defaults if None.""" + if config.love_path is None: + config.love_path = r"C:\Program Files (x86)\Steam\steamapps\common\Balatro\Balatro.exe" + if config.lovely_path is None: + config.lovely_path = r"C:\Program Files (x86)\Steam\steamapps\common\Balatro\version.dll" + + love = Path(config.love_path) + lovely = Path(config.lovely_path) + + if not love.exists(): + raise RuntimeError(f"Balatro executable not found: {love}") + if not lovely.exists(): + raise RuntimeError(f"version.dll not found: {lovely}") + + def build_env(self, config: Config) -> dict[str, str]: + """Build environment.""" + env = os.environ.copy() + env.update(config.to_env()) + return env + + def build_cmd(self, config: Config) -> list[str]: + """Build Windows launch command.""" + assert config.love_path is not None + return [config.love_path] diff --git a/tests/cli/test_platforms.py b/tests/cli/test_platforms.py index 5615668..5ead278 100644 --- a/tests/cli/test_platforms.py +++ b/tests/cli/test_platforms.py @@ -8,9 +8,11 @@ from balatrobot.platforms import VALID_PLATFORMS, get_launcher from balatrobot.platforms.macos import MacOSLauncher from balatrobot.platforms.native import NativeLauncher +from balatrobot.platforms.windows import WindowsLauncher IS_MACOS = platform_module.system() == "Darwin" IS_LINUX = platform_module.system() == "Linux" +IS_WINDOWS = platform_module.system() == "Windows" class TestGetLauncher: @@ -31,10 +33,10 @@ def test_native_returns_native_launcher(self): launcher = get_launcher("native") assert isinstance(launcher, NativeLauncher) - def test_windows_not_implemented(self): - """'windows' raises NotImplementedError.""" - with pytest.raises(NotImplementedError): - get_launcher("windows") + def test_windows_returns_windows_launcher(self): + """'windows' returns WindowsLauncher.""" + launcher = get_launcher("windows") + assert isinstance(launcher, WindowsLauncher) def test_linux_not_implemented(self): """'linux' raises NotImplementedError.""" @@ -127,3 +129,50 @@ def test_build_cmd(self, tmp_path): cmd = launcher.build_cmd(config) assert cmd == ["/usr/bin/love", "/path/to/balatro"] + + +@pytest.mark.skipif(not IS_WINDOWS, reason="Windows only") +class TestWindowsLauncher: + """Tests for WindowsLauncher (Windows only).""" + + def test_validate_paths_missing_balatro_exe(self, tmp_path): + """Raises RuntimeError when Balatro.exe missing.""" + launcher = WindowsLauncher() + config = Config(love_path=str(tmp_path / "nonexistent.exe")) + + with pytest.raises(RuntimeError, match="Balatro executable not found"): + launcher.validate_paths(config) + + def test_validate_paths_missing_version_dll(self, tmp_path): + """Raises RuntimeError when version.dll missing.""" + # Create a fake Balatro.exe + exe_path = tmp_path / "Balatro.exe" + exe_path.touch() + + launcher = WindowsLauncher() + config = Config( + love_path=str(exe_path), + lovely_path=str(tmp_path / "nonexistent.dll"), + ) + + with pytest.raises(RuntimeError, match="version.dll not found"): + launcher.validate_paths(config) + + def test_build_env_no_dll_injection_var(self, tmp_path): + """build_env does not include DLL injection environment variable.""" + launcher = WindowsLauncher() + config = Config(lovely_path=r"C:\path\to\version.dll") + + env = launcher.build_env(config) + + assert "DYLD_INSERT_LIBRARIES" not in env + assert "LD_PRELOAD" not in env + + def test_build_cmd(self, tmp_path): + """build_cmd returns Balatro.exe path.""" + launcher = WindowsLauncher() + config = Config(love_path=r"C:\path\to\Balatro.exe") + + cmd = launcher.build_cmd(config) + + assert cmd == [r"C:\path\to\Balatro.exe"] From 11552a69232e4bb247b69784695de440fba18b58 Mon Sep 17 00:00:00 2001 From: KyleBastien Date: Tue, 23 Dec 2025 22:35:12 -0800 Subject: [PATCH 2/2] Fix formatting issues --- src/balatrobot/platforms/windows.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/balatrobot/platforms/windows.py b/src/balatrobot/platforms/windows.py index adea304..f1583f9 100644 --- a/src/balatrobot/platforms/windows.py +++ b/src/balatrobot/platforms/windows.py @@ -13,9 +13,13 @@ class WindowsLauncher(BaseLauncher): def validate_paths(self, config: Config) -> None: """Validate paths, apply Windows defaults if None.""" if config.love_path is None: - config.love_path = r"C:\Program Files (x86)\Steam\steamapps\common\Balatro\Balatro.exe" + config.love_path = ( + r"C:\Program Files (x86)\Steam\steamapps\common\Balatro\Balatro.exe" + ) if config.lovely_path is None: - config.lovely_path = r"C:\Program Files (x86)\Steam\steamapps\common\Balatro\version.dll" + config.lovely_path = ( + r"C:\Program Files (x86)\Steam\steamapps\common\Balatro\version.dll" + ) love = Path(config.love_path) lovely = Path(config.lovely_path)