feat: add cli support linux proton issue #128 (draft)#162
feat: add cli support linux proton issue #128 (draft)#162charlesmerritt wants to merge 21 commits intocoder:mainfrom
Conversation
Implements LinuxLauncher that auto-detects Steam root, Balatro.exe, Proton installation, version.dll (Lovely), and Wine prefix (compatdata). Sets WINEDLLOVERRIDES="version=n,b" so Wine loads the native Lovely injector instead of its built-in version.dll. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When SSH'd into a Steam Deck, DISPLAY and XAUTHORITY are unset even though gamescope provides an X server. Without these, Proton fails with "Authorization required" and the game window never renders. The Linux launcher now auto-detects both from the system: - DISPLAY from X11 sockets in /tmp/.X11-unix/ - XAUTHORITY from XDG_RUNTIME_DIR/xauth_* or ~/.Xauthority This eliminates the need to manually export these variables before running `balatrobot serve` or the test suite. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds experimental Steam Deck / Proton support by introducing a Linux (Steam/Proton) launcher and documenting/dev-tooling updates to help develop and test on SteamOS.
Changes:
- Implement
LinuxLauncherto auto-detect Steam libraries, Balatro.exe, Proton, compatdata, and set required Proton/X11 environment. - Extend CLI platform dispatch and add a broad Linux/Proton-focused test suite.
- Add Steam Deck–friendly dev script, an example bot script, and documentation updates for Proton usage and tooling setup.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
src/balatrobot/platforms/linux.py |
New Proton-based Linux launcher with Steam/Proton/compatdata detection and X11 env auto-detect. |
src/balatrobot/platforms/__init__.py |
Routes platform="linux" to LinuxLauncher. |
tests/cli/test_platforms.py |
Adds Linux launcher + helper parsing/detection tests. |
scripts/dev.sh |
Adds Makefile-like task runner for environments without make (Steam Deck). |
examples/bot.py |
Adds a small JSON-RPC example bot runnable via uv. |
docs/cli.md |
Documents the experimental Linux (Steam/Proton) platform behavior and requirements. |
docs/installation.md |
Updates installation guidance to reference the new Proton-capable linux platform. |
docs/example-bot.md |
Updates example instructions to run the new example script from repo root. |
docs/contributing.md |
Updates contributor guidance for Proton progress + external Lua tooling + Steam Deck workflow. |
CLAUDE.md |
Updates architecture overview to include Linux (Steam/Proton) platform. |
.gitignore |
Ignores .lua-lsp/ helper directory. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def rpc(method: str, params: dict = {}) -> dict: | ||
| """Send a JSON-RPC 2.0 request to the BalatroBot API.""" | ||
| response = requests.post( | ||
| URL, | ||
| json={ | ||
| "jsonrpc": "2.0", | ||
| "method": method, | ||
| "params": params, |
There was a problem hiding this comment.
rpc() uses a mutable default argument (params={}), which can leak state between calls if the dict is ever mutated. Use params: dict | None = None and create a new dict inside the function when needed.
| def rpc(method: str, params: dict = {}) -> dict: | |
| """Send a JSON-RPC 2.0 request to the BalatroBot API.""" | |
| response = requests.post( | |
| URL, | |
| json={ | |
| "jsonrpc": "2.0", | |
| "method": method, | |
| "params": params, | |
| def rpc(method: str, params: dict | None = None) -> dict: | |
| """Send a JSON-RPC 2.0 request to the BalatroBot API.""" | |
| response = requests.post( | |
| URL, | |
| json={ | |
| "jsonrpc": "2.0", | |
| "method": method, | |
| "params": params or {}, |
| response = requests.post( | ||
| URL, | ||
| json={ | ||
| "jsonrpc": "2.0", | ||
| "method": method, | ||
| "params": params, | ||
| "id": 1, | ||
| }, | ||
| ) |
There was a problem hiding this comment.
The example bot’s requests.post(...) call has no timeout. If the local API is unreachable, this can hang indefinitely and make the example look broken. Consider adding a reasonable timeout (and optionally a clearer exception message for connection errors).
| response = requests.post( | |
| URL, | |
| json={ | |
| "jsonrpc": "2.0", | |
| "method": method, | |
| "params": params, | |
| "id": 1, | |
| }, | |
| ) | |
| try: | |
| response = requests.post( | |
| URL, | |
| json={ | |
| "jsonrpc": "2.0", | |
| "method": method, | |
| "params": params, | |
| "id": 1, | |
| }, | |
| timeout=5, | |
| ) | |
| except requests.exceptions.RequestException as exc: | |
| raise RuntimeError(f"Failed to connect to BalatroBot API at {URL}") from exc |
| print_msg "${YELLOW}Available targets:${RESET}" | ||
| printf " ${GREEN}%-18s${RESET} %s\n" "help" "Show this help message" | ||
| printf " ${GREEN}%-18s${RESET} %s\n" "install" "Install balatrobot and all dependencies (including dev)" | ||
| printf " ${GREEN}%-18s${RESET} %s\n" "lint" "Run ruff linter (check only)" |
There was a problem hiding this comment.
In the help output, the lint target is described as "check only", but cmd_lint runs ruff check --fix ..., which modifies files. Update the help text (or the command) so the behavior matches what’s advertised.
| printf " ${GREEN}%-18s${RESET} %s\n" "lint" "Run ruff linter (check only)" | |
| printf " ${GREEN}%-18s${RESET} %s\n" "lint" "Run ruff linter (apply fixes)" |
| monkeypatch.setattr( | ||
| "balatrobot.platforms.linux._detect_display", | ||
| lambda: ( | ||
| _detect_display.__wrapped__(x11_dir) | ||
| if hasattr(_detect_display, "__wrapped__") | ||
| else None | ||
| ), | ||
| ) |
There was a problem hiding this comment.
This test sets balatrobot.platforms.linux._detect_display twice; the first patch tries to call _detect_display.__wrapped__, but _detect_display isn’t decorated (no __wrapped__), and the patch is overwritten immediately after. This dead/unused monkeypatch adds confusion and doesn’t validate the real detection logic—remove it or refactor _detect_display to accept an injectable socket dir for a deterministic unit test.
| monkeypatch.setattr( | |
| "balatrobot.platforms.linux._detect_display", | |
| lambda: ( | |
| _detect_display.__wrapped__(x11_dir) | |
| if hasattr(_detect_display, "__wrapped__") | |
| else None | |
| ), | |
| ) |
| | Linux (Native) | `~/.config/love/Mods/balatrobot/` | | ||
|
|
||
| > Steam/Proton launcher not supported yet. Track progress in [#128](https://github.com/coder/balatrobot/issues/128) | ||
| > Linux (Steam/Proton) is an experimental platform via the `linux` platform. See the [CLI Reference](cli.md#linux-steamproton-platform) for details. |
There was a problem hiding this comment.
The link target cli.md#linux-steamproton-platform likely doesn’t match the actual heading slug in docs/cli.md (### Linux (Steam/Proton) Platform (Experimental) typically slugs to #linux-steamproton-platform-experimental in MkDocs/Material). This will produce a broken in-page link; adjust the fragment or add an explicit anchor to the CLI heading.
| > Linux (Steam/Proton) is an experimental platform via the `linux` platform. See the [CLI Reference](cli.md#linux-steamproton-platform) for details. | |
| > Linux (Steam/Proton) is an experimental platform via the `linux` platform. See the [CLI Reference](cli.md#linux-steamproton-platform-experimental) for details. |
| sockets = sorted(Path("/tmp/.X11-unix").iterdir()) | ||
| if sockets: | ||
| # Socket names are like X0, X1 — extract the display number | ||
| name = sockets[0].name # e.g. "X0" | ||
| return f":{name[1:]}" |
There was a problem hiding this comment.
In _detect_display(), the code picks the first entry in /tmp/.X11-unix and blindly slices name[1:]. If the directory contains non-display entries (or display sockets like X10), this can return an invalid DISPLAY. Filter entries by a ^X\d+$ pattern and choose the lowest numeric display (or otherwise deterministically choose the intended one).
| sockets = sorted(Path("/tmp/.X11-unix").iterdir()) | |
| if sockets: | |
| # Socket names are like X0, X1 — extract the display number | |
| name = sockets[0].name # e.g. "X0" | |
| return f":{name[1:]}" | |
| display_numbers: list[int] = [] | |
| for entry in Path("/tmp/.X11-unix").iterdir(): | |
| # Socket names are expected to be like "X0", "X1", etc. | |
| m = re.fullmatch(r"X(\d+)", entry.name) | |
| if m: | |
| display_numbers.append(int(m.group(1))) | |
| if display_numbers: | |
| # Choose the lowest numeric display deterministically. | |
| return f":{min(display_numbers)}" |
| runtime_dir = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}") | ||
| matches = glob.glob(os.path.join(runtime_dir, "xauth_*")) | ||
| if matches: | ||
| return matches[0] |
There was a problem hiding this comment.
_detect_xauthority() returns matches[0] from glob.glob, but glob ordering is filesystem-dependent and there can be multiple xauth_* files; this can select a stale/incorrect Xauthority file and break X11 auth. Sort the matches (e.g., by mtime descending) and pick the most recent, or otherwise make the selection deterministic.
| return matches[0] | |
| # Multiple xauth_* files can exist; pick the most recently modified one | |
| latest = max(matches, key=os.path.getmtime) | |
| return latest |
| if self._steam_root is None: | ||
| errors.append( | ||
| "Steam installation not found.\n" | ||
| " Expected: ~/.local/share/Steam or ~/.steam/steam" |
There was a problem hiding this comment.
The Steam-not-found message says it "Expected: ~/.local/share/Steam or ~/.steam/steam", but _STEAM_ROOT_CANDIDATES also includes Snap and Flatpak locations. Update the message to list all supported candidate paths (or print the full candidate list) so users aren’t misled when Steam is installed via Snap/Flatpak.
| " Expected: ~/.local/share/Steam or ~/.steam/steam" | |
| " Searched locations:\n" | |
| + "".join(f" - {path}\n" for path in _STEAM_ROOT_CANDIDATES) |
|
I like the effort you are putting in this PR (e.g. new tests for the platform, dev.sh, bot.py example, ...) but at the end I'd like to only merge the file I don't know if there is difference of the Linux + Proton (Desktop) and Proton (on steamdeck). So maybe it make most sense to treat SteamDeck as another platform (e.g. I'll take a closer look at this PR when you marking as "Ready for Review". |
…m install path candidates to linux launcher, expand .luarc.json documentation for proton
0024ac5 to
ca55228
Compare
Hello, I am working on my first PR here to bring Steam Deck/Proton support to BalatroBot developers.
So far, pretty much everything seems to work, but I still need to fix an error where fixtures are not found which causes all of the Lua tests to fail.
Summary
This PR introduces initial support for running BalatroBot on Steam Deck via Proton. Core functionality appears to work, but Lua tests are currently failing due to missing fixtures. This is a draft PR for early feedback.
Here is what I have added.
Note on deviations between dev.sh and Makefile:
This is a draft PR and any advice/review is appreciated. Here is what I know I have left to do for sure.