-# Borderlands 2 — 2-player split screen (Linux / KDE)
+# Borderlands 2 — 2-player split-screen / dual-monitor co-op on Linux
-Borderlands 2 has **no built-in PC split screen**. This runs **two copies** of the
-game side-by-side that connect over Goldberg's emulated Steam LAN — each with its
-own save, its own identity, and its own controller — auto-tiled on KDE (KWin).
+Runs **two local instances** of Borderlands 2 that connect to each other over
+Goldberg's emulated Steam LAN, so two people can play co-op on one PC — each with
+their own controller, save, and screen. Tested on **Arch + KDE Plasma 6 (Wayland)**.
-**Status: working & validated on this machine.** Both instances start, initialize
-Steamworks + engine, and run simultaneously. Goldberg is already downloaded and
-staged; `setup` has been run. You just need both controllers on, then `run`.
+- **Two monitors:** one instance fullscreen on each (auto-detected).
+- **One monitor:** side-by-side split.
+- Each Xbox controller drives only its own instance.
-## What this install is, and why the setup looks the way it does
+> BL2 has no native PC split-screen; this launches two copies networked over LAN.
+> Only legitimate because you own the game. No game files or saves are committed here.
-Your BL2 is the **native Aspyr Linux build** (a 2012, 32-bit `Borderlands2` ELF) —
-not the Windows/Proton version. Getting split screen out of it took solving three
-real problems, all handled by the script:
+
-1. **The binary is too old for modern system libs** → it's launched inside the
- **Steam scout runtime** (`ubuntu12_32/steam-runtime/run.sh`), which ships the
- old libraries it expects.
-2. **gbe_fork (the modern Goldberg) segfaults this binary** → we use the
- **classic Mr_Goldberg 0.2.5** emulator instead (`goldberg/libsteam_api.so`).
- gbe_fork's experimental *and* regular builds both crash it at a fixed address
- right after Steam init; classic goldberg works.
-3. **The old binary crashes if the emu returns unexpected Steam interfaces** → at
- setup we extract the exact interface versions from the game's original
- `libsteam_api.so` into each copy's `steam_settings/steam_interfaces.txt`.
+## Requirements
-A Windows **proton** mode is also supported (see below); the script auto-detects
-which build is installed.
+- **Borderlands 2 installed via Steam, forced to the Windows build under Proton**
+ (see setup step 1). The native Linux (Aspyr) build is supported as a fallback but
+ is crash-prone with two instances — Proton is the recommended path.
+- Packages: `umu-launcher`, `gamescope` *(optional)*, `qt6-tools` (for `qdbus6`),
+ `bubblewrap`, `libarchive` (`bsdtar`), `curl`, `binutils` (`strings`).
+ ```
+ sudo pacman -S umu-launcher qt6-tools bubblewrap libarchive curl binutils
+ ```
+- Two Xbox controllers (any two distinguishable pads).
-## Playing (native — your current install)
+## Setup
-```
-cd /home/max/Games/bl2-split
-# 1. QUIT STEAM COMPLETELY (see why below)
-# 2. turn on BOTH controllers (the wireless 360 pad idle-sleeps — wake it)
-./bl2-splitscreen.sh run
-```
+1. **Point Steam at the Windows build.** Steam → *Borderlands 2 → Properties →
+ Compatibility →* tick **"Force the use of a specific Steam Play compatibility
+ tool"** → pick **Proton Experimental** (or a stable Proton). Steam re-downloads
+ the Windows depot. (To use the native Linux build instead, skip this — the script
+ auto-detects which is installed.)
+
+2. **Build the two copies:**
+ ```
+ cd bl2-split
+ ./bl2-splitscreen.sh check # verifies prerequisites; shows detected mode
+ ./bl2-splitscreen.sh setup # downloads Goldberg + builds p1/ and p2/
+ ```
+ `setup` auto-downloads the Goldberg emulator (it is **not** committed to this
+ repo) and builds two symlink-mirror copies of the game with per-player Steam
+ identities. First `run` also makes umu download Proton + build each prefix (slow,
+ one time).
-> **Quit Steam first.** While Steam runs, Steam Input creates *virtual* gamepads
-> that the game grabs instead of the real pads — which breaks per-player controller
-> isolation (and can leave input not working). Goldberg replaces Steam, so you don't
-> need it running. The script warns and pauses if it detects Steam.
-
-- Two game windows open (no gamescope). During the intro logos they look
- unaligned — that's expected; **~45 s in (after the splash) they snap to their
- halves.** The delay is because the game resizes itself during the splash, so
- tiling earlier just gets overridden.
-- The split is computed in **logical** pixels (your 2560×1440 panel at 125% scale
- = 2048×1152 logical → two 1024×1152 halves), so it lands right despite scaling.
-- **Player 1** (Xbox Series S|X pad): *Play → host over LAN*.
-- **Player 2** (Xbox 360 wireless pad): *Play → Join → pick the LAN game*.
-- Each window is pinned to one controller and, thanks to SDL background events,
- responds even when the other window has focus.
-- Re-tile any time (e.g. after both reach the menu): `./bl2-splitscreen.sh tile`.
-- Snapping too early (still on the splash)? Raise the wait: `SPLASH_WAIT=70 ./bl2-splitscreen.sh run`.
-- `Ctrl-C` in the terminal kills both instances.
-
-If you change hardware or want to rebuild the copies: `./bl2-splitscreen.sh setup`.
+3. **Play** (turn on both controllers first):
+ ```
+ ./bl2-splitscreen.sh run
+ ```
+ With two monitors each game opens fullscreen on its own display; with one, they
+ tile side-by-side. In-game: **Player 1** *Play → host over LAN*; **Player 2**
+ *Play → Join → pick the LAN game*. `Ctrl-C` kills both.
## Commands
| Command | Does |
|---|---|
-| `./bl2-splitscreen.sh check` | verify prerequisites for the detected mode |
-| `./bl2-splitscreen.sh setup` | build the two patched game copies (already done) |
-| `./bl2-splitscreen.sh run` | launch both halves + tile |
-| `./bl2-splitscreen.sh tile` | (re)tile the two windows on KDE |
-| `./bl2-splitscreen.sh clean` | remove generated copies/saves (real game untouched) |
+| `./bl2-splitscreen.sh fetch` | download the Goldberg emulator into `goldberg/` |
+| `./bl2-splitscreen.sh check` | verify prerequisites; print the detected mode |
+| `./bl2-splitscreen.sh setup` | fetch Goldberg + build `p1/`, `p2/` |
+| `./bl2-splitscreen.sh run` | launch both instances + place windows |
+| `./bl2-splitscreen.sh tile` | re-place the two windows |
+| `./bl2-splitscreen.sh clean` | remove `p1/`, `p2/` (copies + saves + prefixes) |
+
+Useful env overrides: `MODE=native|proton`, `DISPLAY_MODE=dual|split`,
+`ISOLATION=bwrap|sdl`, `SPLASH_WAIT=<secs>`, `STAGGER=<secs>`.
-## How each piece works
+## How it works
| Concern | Solution |
|---|---|
-| Two instances at once | Each copy uses classic Mr_Goldberg (no Steam needed); they LAN together |
-| Runs the ancient binary | Launched via the Steam scout runtime |
-| No crash on Steam init | `steam_interfaces.txt` extracted from the original lib, per copy |
-| Separate saves | Per-player `HOME` (`p1/home`, `p2/home`) — also isolates each `~/.steam` |
-| Two different LAN players | `force_account_name.txt` + `force_steamid.txt` per copy |
-| Half-screen windows | Game runs windowed at the **logical** half size (`-ResX/-ResY` + config), **no gamescope** — gamescope doesn't pass raw controllers to nested clients, so the SDL game would see no pad |
-| Correct size on a scaled display | Split computed from KDE's **logical** screen size (physical ÷ scale), auto-detected via `kscreen-doctor`. Using physical px tiles at the wrong fraction (2/3 + 1/3 at 125%) |
-| Both windows get input | `SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS=1` (SDL otherwise ignores the pad of an unfocused window) |
-| One controller per instance | `bwrap` masks the other pad's `/dev/input` nodes — reliable only with Steam quit (else Steam Input's virtual pads leak through) |
-| Side-by-side placement | KWin script (`qdbus6`) sizes each window to a logical half + removes borders, fired **after** the splash so the game has stopped resizing itself |
-| No 13 GB duplication | `cp -as` symlink-mirror; only the Steam API lib is a real copy |
-
-> The game statically links an **old SDL2** (pre-2.0.9), so it reads controllers via
-> SDL/udev, needs `ALLOW_BACKGROUND_EVENTS` for split focus, and can't use SDL's
-> newer per-VID/PID filtering — hence bwrap for isolation.
-
-## Switching to the Windows / Proton version (optional)
-
-If the native port misbehaves, the Windows build under Proton is an alternative:
-
-1. Steam → **Borderlands 2 → Properties → Compatibility →** *Force a specific
- Steam Play compatibility tool* → e.g. Proton Experimental.
-2. Steam re-downloads the **Windows** depot (`Borderlands2.exe`).
-3. Re-run the script — it auto-detects **proton** mode and uses the already-staged
- Windows `goldberg/steam_api.dll` (gbe_fork) via `umu-run`:
- ```
- ./bl2-splitscreen.sh check # mode: proton
- ./bl2-splitscreen.sh setup && ./bl2-splitscreen.sh run
- ```
- First launch downloads Proton + builds each Wine prefix (slow, one time).
+| Two instances at once | Each copy uses **Goldberg** (`steam_api.dll`) instead of real Steam; they connect over emulated LAN |
+| Goldberg actually loads | The `.exe` is copied **real** (not symlinked) so Wine loads DLLs from our dir, not the real install's `steam_api.dll` |
+| Two different LAN players | Per-copy `force_account_name.txt` + `force_steamid.txt` |
+| No startup crash | `steam_interfaces.txt` extracted from the original Steam lib per copy |
+| Separate saves | Each instance has its own Proton prefix (`p1/prefix`, `p2/prefix`) |
+| Co-op finds each other | Goldberg `custom_broadcasts.txt` → `127.0.0.1` (broadcast doesn't cross the two Proton containers) |
+| Skips slow "Creating online session" | Goldberg `configs.main.ini` → `offline=1` |
+| Windowed at the right size | `-windowed -ResX -ResY`; **no gamescope** (it hides raw controllers from nested clients) |
+| One controller per instance | **bwrap** masks the other pad's `/dev/input` nodes (the Xbox pads are evdev-only, no hidraw) |
+| Window placement | KWin script via `qdbus6` — one monitor each (dual) or side-by-side (single) |
+| No intro-logo wait | Intro movies disabled in each prefix's `WillowEngine.ini` |
+| No 13 GB duplication | `cp -as` symlink-mirror; only `steam_api.dll` + the `.exe` are real copies |
+
+## What's in git (and what isn't)
+
+**Committed** (enough for anyone to reproduce):
+```
+bl2-splitscreen.sh the launcher/setup script
+README.md this file
+.gitignore
+goldberg/.gitkeep empty dir; setup downloads binaries into it
+p1/.gitkeep p2/.gitkeep
+```
-> Native and Windows builds share the same folder, so only one is installed at a
-> time. Switching modes = switching what Steam has downloaded.
+**Never committed** (`.gitignore`):
+- `goldberg/steam_api.dll`, `goldberg/libsteam_api.so` — the Goldberg emulator
+ binaries. They implement Steam's proprietary API, so they aren't redistributed
+ here; `setup` downloads them from the upstream releases (gbe_fork / Mr_Goldberg).
+- `p1/`, `p2/` (except `.gitkeep`) — the game copies, **your save games**, and Wine
+ prefixes. Machine-specific and large.
+- `*.log`.
-## Troubleshooting
+So another person clones the repo, forces Proton on their own BL2 in Steam, runs
+`./bl2-splitscreen.sh setup`, and everything else is pulled/generated locally.
-- **They don't see each other on LAN:** let Player 1 host first (the script starts
- it ~10 s earlier). Both use classic goldberg's LAN broadcast; each player's own
- `~/.steam`/config is isolated via the per-player HOME. If needed, check each
- copy's `steam_settings/`.
-- **Controllers don't work at all:** make sure **Steam is fully quit** before `run`
- (Steam Input's virtual pads block the real ones). Native runs the game *without*
- gamescope for this reason — gamescope hides raw controllers from nested clients.
-- **Both pads control both screens / wrong window got a controller:** almost always
- Steam still running (virtual pads bypass the `bwrap` mask). Quit Steam. Isolation
- is `ISOLATION=bwrap` (default); masking real pad nodes only works with Steam off.
-- **Halves look uneven (one fills ~2/3, the other ~1/3):** display-scaling mismatch
- — the script now auto-detects the *logical* screen size (`kscreen-doctor`), but if
- detection fails set it explicitly, e.g. `SCREEN_W=2048 SCREEN_H=1152 ./bl2-splitscreen.sh run`.
-- **Windows tile while still on the splash / too early:** raise `SPLASH_WAIT`
- (seconds), e.g. `SPLASH_WAIT=70 ./bl2-splitscreen.sh run`, or just re-run
- `./bl2-splitscreen.sh tile` once both are at the menu.
-- **A game crashes on launch:** confirm `./bl2-splitscreen.sh check` is all green;
- the native path needs the scout runtime + classic goldberg + `steam_interfaces.txt`
- (all set up by `setup`).
-- **Reset everything:** `./bl2-splitscreen.sh clean`.
-
-## Files
+## Troubleshooting
-```
-bl2-split/
-├── bl2-splitscreen.sh the launcher
-├── goldberg/
-│ ├── libsteam_api.so classic Mr_Goldberg 0.2.5, 32-bit (native mode)
-│ └── steam_api.dll gbe_fork, Windows 32-bit (proton mode)
-├── p1/ , p2/ per-player: game/ (symlink mirror) + home/ (saves)
-└── README.md
-```
+- **Both pads control both screens / keyboard controls both:** the games read input
+ globally. Controllers are isolated by `bwrap` device-masking (default). Keyboard &
+ mouse are *not* isolated — use a controller per player; kb/mouse is only for menus.
+- **A pad drives the wrong screen or none:** ensure `ISOLATION=bwrap` (default). If
+ **Steam is running**, its Steam Input virtual pads can interfere — quit Steam.
+- **Stuck on "Creating online session":** should be quick with `offline=1` (set by
+ setup). If a co-op *join* ever breaks, remove the offline setting:
+ `rm p{1,2}/game/Binaries/Win32/steam_settings/configs.main.ini`
+- **They don't see each other on LAN:** Player 1 hosts first (the script staggers
+ launches). Discovery uses `custom_broadcasts.txt` (localhost).
+- **A window won't sit on its monitor:** re-run `./bl2-splitscreen.sh tile`; raise
+ `SPLASH_WAIT` if it fires before the game settles.
+- **Reset everything:** `./bl2-splitscreen.sh clean` (removes copies + saves; the
+ real Steam install is untouched).
+
+## Native (Aspyr Linux) fallback
+
+If Steam has the native Linux build installed, the script auto-detects `native` mode:
+it uses the **classic Mr_Goldberg** `.so` (gbe_fork crashes that old binary), runs
+inside the Steam scout runtime, and isolates controllers the same way. It works, but
+the second instance is prone to crashing — prefer the Proton path.
MODE="$(detect_mode)"
[ "$MODE" = unknown ] && die "Cannot find BL2 - neither $NATIVE_BIN nor $WIN_EXE_REL under $GAME_DIR"
if [ -z "$ISOLATION" ]; then ISOLATION=bwrap; fi # device-masking works for both modes
-GOLDBERG_SO="$BASE/goldberg/libsteam_api.so" # native
-GOLDBERG_DLL="$BASE/goldberg/steam_api.dll" # proton
+GOLDBERG_SO="$BASE/goldberg/libsteam_api.so" # native (classic Mr_Goldberg)
+GOLDBERG_DLL="$BASE/goldberg/steam_api.dll" # proton (gbe_fork)
+
+# Goldberg emulator downloads (NOT committed to git - fetched by `fetch`). Pinned
+# to known-good releases; override via env if these ever move.
+GBE_FORK_TAG="${GBE_FORK_TAG:-release-2026_05_30}"
+GBE_FORK_WIN_URL="${GBE_FORK_WIN_URL:-https://github.com/Detanup01/gbe_fork/releases/download/$GBE_FORK_TAG/emu-win-release.7z}"
+CLASSIC_GOLDBERG_URL="${CLASSIC_GOLDBERG_URL:-https://gitlab.com/Mr_Goldberg/goldberg_emulator/uploads/2524331e488ec6399c396cf48bbe9903/Goldberg_Lan_Steam_Emu_v0.2.5.zip}"
resolve_pad() { local m; for m in $1; do [ -e "$m" ] && { readlink -f "$m"; return; }; done; }
+# --------------------------------------------------------------- fetch deps -
+# Download the Goldberg emulator binaries (kept out of git). Proton needs the
+# gbe_fork Windows steam_api.dll; native needs the classic Mr_Goldberg Linux .so.
+cmd_fetch() { # downloads the Goldberg binary the detected MODE needs
+ command -v bsdtar >/dev/null || die "bsdtar missing (sudo pacman -S libarchive)"
+ command -v curl >/dev/null || die "curl missing (sudo pacman -S curl)"
+ mkdir -p "$BASE/goldberg"
+ local tmp; tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' RETURN
+
+ if [ "$MODE" = proton ]; then
+ if [ -f "$GOLDBERG_DLL" ]; then ok "gbe_fork steam_api.dll already present"; return; fi
+ say "Downloading gbe_fork (Windows) $GBE_FORK_TAG ..."
+ curl -fsSL -o "$tmp/win.7z" "$GBE_FORK_WIN_URL" || die "download failed: $GBE_FORK_WIN_URL"
+ bsdtar -xf "$tmp/win.7z" -C "$tmp" release/experimental/x86/steam_api.dll \
+ || die "could not extract steam_api.dll from the archive"
+ cp "$tmp/release/experimental/x86/steam_api.dll" "$GOLDBERG_DLL"
+ file "$GOLDBERG_DLL" | grep -qi "PE32 .*i386" && ok "steam_api.dll (proton) fetched" \
+ || die "fetched steam_api.dll is not the expected 32-bit PE"
+ else
+ if [ -f "$GOLDBERG_SO" ]; then ok "classic goldberg libsteam_api.so already present"; return; fi
+ say "Downloading classic Mr_Goldberg (Linux) ..."
+ curl -fsSL -o "$tmp/lin.zip" "$CLASSIC_GOLDBERG_URL" || die "download failed: $CLASSIC_GOLDBERG_URL"
+ bsdtar -xf "$tmp/lin.zip" -C "$tmp" linux/x86/libsteam_api.so \
+ || die "could not extract libsteam_api.so from the archive"
+ cp "$tmp/linux/x86/libsteam_api.so" "$GOLDBERG_SO"
+ file "$GOLDBERG_SO" | grep -q "ELF 32-bit" && ok "libsteam_api.so (native) fetched" \
+ || die "fetched libsteam_api.so is not the expected 32-bit ELF"
+ fi
+}
+
# ------------------------------------------------------------------ check ---
cmd_check() {
local fail=0
# other via UDP broadcast, so co-op hangs at "Creating online session". Tell the
# emu to send discovery directly to localhost so they always find each other.
printf '127.0.0.1\n' > "$ss/custom_broadcasts.txt"
+ # BL2 stalls on "Creating online session" waiting on (unreachable) online Steam
+ # servers; offline mode makes the emu report Steam offline so it skips that wait
+ # and goes to LAN. LAN co-op still works. Remove this file if it ever breaks join.
+ cat > "$ss/configs.main.ini" <<EOF
+[main::connectivity]
+offline=1
+EOF
}
# Old games hard-crash if the emu returns interfaces they don't expect. Extract
}
cmd_setup() {
+ cmd_fetch # download Goldberg if not present
REQUIRE_PADS=0 cmd_check || die "check failed - resolve the above before setup"
say ""; say "== Building player copies ($MODE) =="
build_player 1 "Player1" "76561197960287930"
cmd_clean() { say "Removing generated copies/saves (real game untouched)..."; rm -rf "$BASE/p1" "$BASE/p2"; ok "Done."; }
case "${1:-}" in
+ fetch) cmd_fetch ;;
check) cmd_check ;;
setup) cmd_setup ;;
run) cmd_run ;;
tile) cmd_tile ;;
clean) cmd_clean ;;
- *) say "Usage: $0 {check|setup|run|tile|clean} (MODE=native|proton to force)"; exit 1 ;;
+ *) say "Usage: $0 {fetch|check|setup|run|tile|clean} (MODE=native|proton to force)"; exit 1 ;;
esac