From: Max Vilimpoc Date: Mon, 13 Jul 2026 07:36:10 +0000 (+0200) Subject: Initial commit X-Git-Url: https://vilimpoc.org/repos/bl2-split/commitdiff_plain/cb05cf8e0e0cab3cbd1b77f233aaedbd30ecffa2 Initial commit --- cb05cf8e0e0cab3cbd1b77f233aaedbd30ecffa2 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b94f4c7 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# Borderlands 2 — 2-player split screen (Linux / KDE) + +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). + +**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`. + +## What this install is, and why the setup looks the way it does + +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`. + +A Windows **proton** mode is also supported (see below); the script auto-detects +which build is installed. + +## Playing (native — your current install) + +``` +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 +``` + +> **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`. + +## 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) | + +## How each piece 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). + +> Native and Windows builds share the same folder, so only one is installed at a +> time. Switching modes = switching what Steam has downloaded. + +## Troubleshooting + +- **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 + +``` +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 +``` diff --git a/bl2-splitscreen.sh b/bl2-splitscreen.sh new file mode 100755 index 0000000..8406345 --- /dev/null +++ b/bl2-splitscreen.sh @@ -0,0 +1,407 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# Borderlands 2 - two-player split screen on Linux +# +# Borderlands 2 has NO native PC split screen, so this launches TWO independent +# instances that connect over Goldberg's emulated Steam LAN. Each instance gets +# its own save, its own Goldberg identity, one controller, and half the screen +# (gamescope), auto-tiled side-by-side on KDE Plasma (KWin). +# +# Two build layouts are supported and AUTO-DETECTED: +# * native - the Aspyr native Linux port (ELF `Borderlands2`) -> no Proton +# * proton - the Windows build (`Binaries/Win32/Borderlands2.exe`) -> umu/Proton +# Force one with: MODE=native|proton ./bl2-splitscreen.sh ... +# +# Usage: +# ./bl2-splitscreen.sh check # verify prerequisites for the detected mode +# ./bl2-splitscreen.sh setup # build the two patched game copies +# ./bl2-splitscreen.sh run # launch both halves + tile them +# ./bl2-splitscreen.sh tile # (re)tile the two windows on KDE +# ./bl2-splitscreen.sh clean # remove generated copies (not the game) +# --------------------------------------------------------------------------- +set -uo pipefail + +# ============================ CONFIG ======================================= +GAME_DIR="/home/max/.local/share/Steam/steamapps/common/Borderlands 2" +APPID=49520 +BASE="/home/max/Games/bl2-split" + +# --- Display geometry (per-player render size GW/GH and window position GX/GY) -- +# Monitors are read from KDE in LOGICAL px as "X,Y WxH", sorted left-to-right. +_mons=$(kscreen-doctor -o 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g' \ + | awk '/^Output:/{e=0} /enabled/{e=1} /Geometry:/&&e{print $2,$3}' | sort -t, -k1 -n) +_mi=1 +while read -r _pos _size; do + [ -z "$_pos" ] && continue + _MX[$_mi]=${_pos%,*}; _MY[$_mi]=${_pos#*,} + _MW[$_mi]=${_size%x*}; _MH[$_mi]=${_size#*x} + _mi=$((_mi+1)) +done <<< "$_mons" +MON_COUNT=$((_mi-1)) +[ "${MON_COUNT:-0}" -ge 1 ] 2>/dev/null || { _MW[1]=2560; _MH[1]=1440; _MX[1]=0; _MY[1]=0; MON_COUNT=1; } + +# DISPLAY_MODE: +# dual (default when >=2 monitors) -> each player gets a whole monitor, "fullscreen". +# split (1 monitor) -> both on one monitor, side-by-side. Each half is 16:9 and +# vertically centered, because a tall 8:9 window clips BL2's landscape UI, +# and a full-width window would trip BL2 into exclusive fullscreen. +if [ "$MON_COUNT" -ge 2 ]; then DISPLAY_MODE="${DISPLAY_MODE:-dual}"; else DISPLAY_MODE="${DISPLAY_MODE:-split}"; fi + +if [ "$DISPLAY_MODE" = dual ]; then + GW[1]=${_MW[1]}; GH[1]=${_MH[1]}; GX[1]=${_MX[1]}; GY[1]=${_MY[1]} + GW[2]=${_MW[2]}; GH[2]=${_MH[2]}; GX[2]=${_MX[2]}; GY[2]=${_MY[2]} +else + _hw=$(( ${_MW[1]} / 2 )); _hh=$(( _hw * 9 / 16 )) + [ "$_hh" -gt "${_MH[1]}" ] && _hh=${_MH[1]} + _wy=$(( ${_MY[1]} + (${_MH[1]} - _hh) / 2 )) + GW[1]=$_hw; GH[1]=$_hh; GX[1]=${_MX[1]}; GY[1]=$_wy + GW[2]=$_hw; GH[2]=$_hh; GX[2]=$(( ${_MX[1]} + _hw )); GY[2]=$_wy +fi + +# Controllers, matched by STABLE /dev/input/by-id names (survive replug/reboot). +P1_PAD_GLOB="/dev/input/by-id/*Microsoft_Controller_*-event-joystick" # Xbox Series S|X (wired) +P2_PAD_GLOB="/dev/input/by-id/*Wireless_Receiver*-event-joystick" # Xbox 360 Wireless Receiver +P1_VIDPID="0x045e/0x0b12" # Xbox Series S|X (SDL hint; used in proton mode) +P2_VIDPID="0x045e/0x0719" # Xbox 360 Wireless Receiver + +# Controller isolation: "bwrap" (mask other pad's /dev nodes; backend-agnostic) +# or "sdl" (VID/PID hint; only works for SDL games). Default depends on MODE: +# native -> bwrap (the Aspyr binary is NOT SDL; the hint does nothing) +# proton -> sdl (Proton is SDL-based; avoids bwrap<->pressure-vessel nesting) +ISOLATION="${ISOLATION:-}" + +# Proton build for umu (proton mode). Empty = umu auto-selects/downloads UMU-Proton. +PROTONPATH="${PROTONPATH:-}" + +# Native mode: the 2012 Aspyr binary must run inside the Steam scout runtime (the +# old libs it was built against) with the CLASSIC Mr_Goldberg emu. gbe_fork (both +# experimental and regular) segfaults this binary; classic goldberg works. +NATIVE_RUNTIME="${NATIVE_RUNTIME:-/home/max/.steam/steam/ubuntu12_32/steam-runtime/run.sh}" + +# Seconds to wait before auto-tiling, i.e. how long the intro logos/movies take to +# clear on your machine. Bump it if the windows snap into place too early (while +# still on the splash). You can always re-run `./bl2-splitscreen.sh tile`. +SPLASH_WAIT="${SPLASH_WAIT:-45}" + +# Seconds to wait after launching Player 1 before launching Player 2. Giving P1 +# time to reach the menu and start hosting reduces a co-op-startup race that can +# crash the second (fragile Aspyr) instance. +STAGGER="${STAGGER:-25}" +# =========================================================================== + +c_red=$'\e[31m'; c_grn=$'\e[32m'; c_yel=$'\e[33m'; c_dim=$'\e[2m'; c_rst=$'\e[0m' +say() { printf '%s\n' "$*"; } +ok() { printf '%s✓%s %s\n' "$c_grn" "$c_rst" "$*"; } +warn() { printf '%s!%s %s\n' "$c_yel" "$c_rst" "$*"; } +die() { printf '%s✗ %s%s\n' "$c_red" "$*" "$c_rst" >&2; exit 1; } + +# -------------------------------------------------------- mode detection --- +WIN_EXE_REL="Binaries/Win32/Borderlands2.exe" +NATIVE_BIN="Borderlands2" +detect_mode() { + if [ -n "${MODE:-}" ]; then echo "$MODE"; return; fi + if [ -f "$GAME_DIR/$WIN_EXE_REL" ]; then echo proton + elif [ -x "$GAME_DIR/$NATIVE_BIN" ]; then echo native + else echo unknown; fi +} +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 + +resolve_pad() { local m; for m in $1; do [ -e "$m" ] && { readlink -f "$m"; return; }; done; } + +# ------------------------------------------------------------------ check --- +cmd_check() { + local fail=0 + say "== Mode: ${c_grn}$MODE${c_rst} (isolation=$ISOLATION) ==" + say ""; say "== Prerequisites ==" + command -v qdbus6 >/dev/null && ok "qdbus6 (KWin tiling)" || warn "qdbus6 missing - auto-tiling disabled ${c_dim}(sudo pacman -S qt6-tools)${c_rst}" + [ "$ISOLATION" = bwrap ] && { command -v bwrap >/dev/null && ok "bwrap" || { warn "bwrap missing"; fail=1; }; } + if [ "$MODE" = proton ]; then + command -v gamescope >/dev/null && ok "gamescope" || { warn "gamescope missing ${c_dim}(sudo pacman -S gamescope)${c_rst}"; fail=1; } + command -v umu-run >/dev/null && ok "umu-run" || { warn "umu-run missing ${c_dim}(sudo pacman -S umu-launcher)${c_rst}"; fail=1; } + else + [ -f "$NATIVE_RUNTIME" ] && ok "Steam scout runtime" || { warn "scout runtime missing: $NATIVE_RUNTIME"; fail=1; } + command -v strings >/dev/null && ok "strings (interface extraction)" || { warn "strings missing ${c_dim}(sudo pacman -S binutils)${c_rst}"; fail=1; } + fi + + say ""; say "== Game ==" + if [ "$MODE" = native ]; then + [ -x "$GAME_DIR/$NATIVE_BIN" ] && file "$GAME_DIR/$NATIVE_BIN" | grep -q ELF \ + && ok "native ELF: $NATIVE_BIN" || { warn "native binary missing"; fail=1; } + else + [ -f "$GAME_DIR/$WIN_EXE_REL" ] && ok "Borderlands2.exe" || { warn "exe missing: $WIN_EXE_REL"; fail=1; } + fi + + say ""; say "== Goldberg ==" + if [ "$MODE" = native ]; then + if [ -f "$GOLDBERG_SO" ] && file "$GOLDBERG_SO" | grep -q "ELF 32-bit"; then ok "libsteam_api.so (classic Mr_Goldberg, 32-bit ELF)" + else warn "missing/invalid $GOLDBERG_SO (need classic Mr_Goldberg linux/x86 libsteam_api.so)"; fail=1; fi + else + if [ -f "$GOLDBERG_DLL" ] && file "$GOLDBERG_DLL" | grep -qi "PE32 .*i386"; then ok "steam_api.dll (PE32 i386)" + else warn "missing/invalid $GOLDBERG_DLL (need Windows x86 gbe_fork experimental)"; fail=1; fi + fi + + say ""; say "== Controllers ==" + local p1 p2 padfail=0; p1=$(resolve_pad "$P1_PAD_GLOB"); p2=$(resolve_pad "$P2_PAD_GLOB") + [ -n "$p1" ] && ok "Player 1 pad -> $p1" || { warn "Player 1 pad not connected"; padfail=1; } + [ -n "$p2" ] && ok "Player 2 pad -> $p2" || { warn "Player 2 pad not connected (turn it on)"; padfail=1; } + # Controllers only matter for 'run'; setup passes REQUIRE_PADS=0. + [ "${REQUIRE_PADS:-1}" = 1 ] && [ "$padfail" = 1 ] && fail=1 + + say "" + [ "$fail" = 0 ] && ok "All good - run: ./bl2-splitscreen.sh setup" || warn "Fix the above, then re-run check." + return $fail +} + +# ------------------------------------------------------------------ setup --- +write_goldberg_settings() { # $1=dir $2=account $3=steamid + local ss="$1/steam_settings"; mkdir -p "$ss" + printf '%s\n' "$APPID" > "$1/steam_appid.txt" + cat > "$ss/configs.user.ini" < "$ss/account_name.txt" + printf '%s\n' "$2" > "$ss/force_account_name.txt" # classic goldberg name override + printf '%s\n' "$3" > "$ss/force_steamid.txt" # classic goldberg id override + printf '%s\n' "$3" > "$ss/user_steam_id.txt" + # Two same-host instances (each in its own Proton container) may not reach each + # 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" +} + +# Old games hard-crash if the emu returns interfaces they don't expect. Extract +# the exact interface versions from the game's ORIGINAL Steam lib. +extract_interfaces() { # $1=steam_settings dir $2=path to original steam_api lib + strings -n 5 "$2" 2>/dev/null | grep -E \ + '^(SteamClient|SteamGameServerStats|SteamGameServer|SteamUserStats|SteamUser|SteamFriends|SteamUtils|SteamMatchMakingServers|SteamMatchMaking|SteamRemoteStorage|SteamScreenshots|SteamHTTP|SteamController|SteamUGC|SteamAppList|SteamApps|SteamMusicRemote|SteamMusic|SteamHTMLSurface|SteamInventory|SteamVideo|SteamParentalSettings|SteamInput|SteamNetworkingUtils|SteamNetworkingSockets|SteamNetworkingMessages|SteamNetworking|SteamParties|STEAMAPPS|STEAMUSERSTATS|STEAMHTTP|STEAMSCREENSHOTS|STEAMUGC|STEAMREMOTESTORAGE|STEAMCONTROLLER|STEAMMUSIC|STEAMAPPLIST|STEAMUSER|STEAMFRIENDS|STEAMUTILS|STEAMNETWORKING)[A-Za-z0-9_]*[0-9]{3}$' \ + | sort -u > "$1/steam_interfaces.txt" +} + +build_player() { # $1=n $2=account $3=steamid + local n="$1" acct="$2" sid="$3" pg="$BASE/p$1/game" + say " [$n] symlink game copy -> $pg" + rm -rf "$BASE/p$n/game"; mkdir -p "$BASE/p$n/home" + cp -as "$GAME_DIR/." "$pg/" || die "cp -as failed (need GNU coreutils)" + if [ "$MODE" = native ]; then + rm -f "$pg/libsteam_api.so"; cp "$GOLDBERG_SO" "$pg/libsteam_api.so" + write_goldberg_settings "$pg" "$acct" "$sid" + extract_interfaces "$pg/steam_settings" "$GAME_DIR/libsteam_api.so" + [ -s "$pg/steam_settings/steam_interfaces.txt" ] || warn " [$n] no interfaces extracted (game may crash)" + else + local w="$pg/Binaries/Win32" + # Wine resolves a SYMLINKED exe to its target directory and loads DLLs from + # THERE (the real steam_api.dll -> "Steam must be running"), bypassing our + # Goldberg dll. Make the exes real copies so DLL search happens in our dir. + local e real + for e in Borderlands2.exe Launcher.exe; do + [ -L "$w/$e" ] && { real=$(readlink -f "$w/$e"); cp --remove-destination "$real" "$w/$e"; } + done + # keep a copy of the original Windows lib before overwriting, for interfaces + [ -f "$w/steam_api.dll.orig" ] || cp -L "$w/steam_api.dll" "$w/steam_api.dll.orig" 2>/dev/null + write_goldberg_settings "$w" "$acct" "$sid" + extract_interfaces "$w/steam_settings" "$w/steam_api.dll.orig" + rm -f "$w/steam_api.dll"; cp "$GOLDBERG_DLL" "$w/steam_api.dll" + mkdir -p "$BASE/p$n/prefix" + fi + ok " [$n] account '$acct' (steamid $sid)" +} + +cmd_setup() { + REQUIRE_PADS=0 cmd_check || die "check failed - resolve the above before setup" + say ""; say "== Building player copies ($MODE) ==" + build_player 1 "Player1" "76561197960287930" + build_player 2 "Player2" "76561197960287931" + say ""; ok "Setup complete. Launch with: ./bl2-splitscreen.sh run" +} + +# -------------------------------------------------------------------- run --- +# Pin the game to windowed half-screen in its own config, so it doesn't apply a +# saved fullscreen mode after loading (which overrides -windowed and unstacks the +# tiling). Section-aware: ResX/Fullscreen appear in several sections. +patch_native_config() { # $1=player home $2=width $3=height + local f + f=$(find "$1" -ipath "*willowgame/config/willowengine.ini" 2>/dev/null | head -1) + [ -f "$f" ] || return 0 + # Config uses CRLF: strip trailing CR for matching, re-add it on output. + awk -v W="$2" -v H="$3" ' + { cr = (sub(/\r$/,"") ? "\r" : "") } + /^\[/ { sec=$0 } + { + l=$0 + if (sec=="[WinDrv.WindowsClient]") { + if (l ~ /^StartupFullscreen=/) l="StartupFullscreen=False" + else if (l ~ /^StartupResolutionX=/) l="StartupResolutionX=" W + else if (l ~ /^StartupResolutionY=/) l="StartupResolutionY=" H + } else if (sec=="[SystemSettings]") { + if (l ~ /^Fullscreen=/) l="Fullscreen=False" + else if (l ~ /^WindowedFullscreen=/) l="WindowedFullscreen=False" + else if (l ~ /^ResX=/) l="ResX=" W + else if (l ~ /^ResY=/) l="ResY=" H + } else if (sec=="[FullScreenMovie]") { + if (l ~ /^StartupMovies=/) next # drop the intro logos + else if (l ~ /^bForceNoMovies=/) l="bForceNoMovies=TRUE" + } + printf "%s%s\n", l, cr + }' "$f" > "$f.tmp" && mv "$f.tmp" "$f" +} + +# Proton keeps its config inside the Wine prefix; drop the intro movies there so +# the game goes straight to the menu (resolution is handled by -ResX/-ResY args). +patch_proton_config() { # $1 = prefix dir + local f + f=$(find "$1/drive_c/users" -ipath "*Borderlands 2*Config/WillowEngine.ini" 2>/dev/null | head -1) + [ -f "$f" ] || return 0 + awk ' + { cr = (sub(/\r$/,"") ? "\r" : "") } + /^\[/ { sec=$0 } + { l=$0 + if (sec=="[FullScreenMovie]") { if (l ~ /^StartupMovies=/) next; else if (l ~ /^bForceNoMovies=/) l="bForceNoMovies=TRUE" } + printf "%s%s\n", l, cr }' "$f" > "$f.tmp" && mv "$f.tmp" "$f" +} + +mask_other_pad_args() { # bwrap args hiding every pad except kept node $1 + local keep="$1" node other g m + for g in $P1_PAD_GLOB $P2_PAD_GLOB; do for m in $g; do + [ -e "$m" ] || continue + node=$(readlink -f "$m"); [ "$node" = "$keep" ] && continue + printf -- '--bind /dev/null %s ' "$node" + other="${m%-event-joystick}-joystick" + [ -e "$other" ] && printf -- '--bind /dev/null %s ' "$(readlink -f "$other")" + done; done +} + +launch_player() { # $1=n $2=pad-node $3=vidpid -> sets LAUNCHED_PID + local n="$1" pad="$2" vidpid="$3" pg="$BASE/p$1/game" wd + local rw="${GW[$n]}" rh="${GH[$n]}" # this player's render size + local -a env cmd + # No gamescope in either mode: it doesn't pass raw controllers to nested clients, + # so the game sees no pad. Run the game windowed and let KWin place it. + if [ "$MODE" = native ]; then + local home="$BASE/p$n/home"; wd="$pg" + patch_native_config "$home" "$rw" "$rh" # pin windowed size in the config + env=( "HOME=$home" "XDG_DATA_HOME=$home/.local/share" "XDG_CONFIG_HOME=$home/.config" + "LD_LIBRARY_PATH=$pg:${LD_LIBRARY_PATH:-}" + "SteamAppId=$APPID" "SteamGameId=$APPID" + "SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS=1" ) + cmd=( "$NATIVE_RUNTIME" "./$NATIVE_BIN" -windowed "-ResX=$rw" "-ResY=$rh" ) + else + wd="$pg/Binaries/Win32" + patch_proton_config "$BASE/p$n/prefix" # skip intro movies (if prefix exists) + env=( "WINEPREFIX=$BASE/p$n/prefix" "GAMEID=0" "STORE=none" ) + # Only use the SDL VID/PID filter in "sdl" mode. With bwrap masking it is + # redundant AND can over-filter (it wrongly dropped the 360 pad), so skip it. + [ "$ISOLATION" = sdl ] && env+=( "SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=$vidpid" ) + [ -n "$PROTONPATH" ] && env+=( "PROTONPATH=$PROTONPATH" ) + cmd=( umu-run "$pg/$WIN_EXE_REL" -windowed "-ResX=$rw" "-ResY=$rh" ) + fi + + # bwrap (if used) masks the other pad's /dev/input nodes for this instance. + local -a inner + if [ "$ISOLATION" = bwrap ]; then + local -a maskargs=(); read -ra maskargs < <(mask_other_pad_args "$pad") + inner=( bwrap --dev-bind / / --die-with-parent --chdir "$wd" "${maskargs[@]}" -- "${cmd[@]}" ) + else + inner=( "${cmd[@]}" ) + fi + + say "${c_dim}[$n] pad=$pad ${rw}x${rh} @ ${GX[$n]},${GY[$n]} log=$BASE/p$n/log${c_rst}" + # Background in the CURRENT shell (not a $() subshell) so the job is a real + # child that cmd_run can wait on; hand the PID back via a global. + ( cd "$wd" || exit 1; exec env "${env[@]}" "${inner[@]}" ) >"$BASE/p$n/log" 2>&1 & + LAUNCHED_PID=$! +} + +# --------------------------------------------------------- KDE/KWin tiling -- +# One-shot: place each BL2 window on its half in LOGICAL coordinates. Run AFTER +# the splash movies so the game has settled at its configured size (tiling during +# the splash fights the game and looks wrong). All geometry is logical px. +cmd_tile() { + command -v qdbus6 >/dev/null || { warn "qdbus6 not found - cannot auto-tile (drag windows manually)"; return 0; } + # Left/right split, both windows vertically centered (WIN_Y). + # Per-window geometry: window[0]=player1, window[1]=player2 (launch order). + local js; js="$(mktemp --suffix=.js)" + cat > "$js" <=0 || s.indexOf("steam_app")>=0; + }); + wins.sort(function(a,b){ return (""+a.internalId)<(""+b.internalId)?-1:1; }); + for(var i=0;i/dev/null 2>&1 + qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.start >/dev/null 2>&1 + qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.unloadScript bl2tile >/dev/null 2>&1 + rm -f "$js" + ok "Placed windows (${DISPLAY_MODE}: ${GW[1]}x${GH[1]} + ${GW[2]}x${GH[2]})." +} + +cmd_run() { + [ -d "$BASE/p1/game" ] && [ -d "$BASE/p2/game" ] || die "run setup first" + local p1 p2; p1=$(resolve_pad "$P1_PAD_GLOB"); p2=$(resolve_pad "$P2_PAD_GLOB") + [ -n "$p1" ] || die "Player 1 controller not connected" + [ -n "$p2" ] || die "Player 2 controller not connected (turn it on)" + + # Steam Input (active while Steam runs) injects VIRTUAL gamepads that SDL grabs + # instead of the real pads, which defeats per-instance controller isolation. + # Goldberg replaces Steam, so it isn't needed while playing. + if pgrep -x steam >/dev/null; then + warn "Steam is running - Steam Input's virtual gamepads can break per-player" + warn "controller isolation. If a pad drives the wrong window, QUIT STEAM" + warn "(Goldberg replaces it). Continuing in 5s..." + sleep 5 + fi + + say "== Launching Borderlands 2 (mode=$MODE, isolation=$ISOLATION, display=$DISPLAY_MODE) ==" + say "${c_dim}P1 ${GW[1]}x${GH[1]}@${GX[1]},${GY[1]} P2 ${GW[2]}x${GH[2]}@${GX[2]},${GY[2]}${c_rst}" + local pid1 pid2 + launch_player 1 "$p1" "$P1_VIDPID"; pid1=$LAUNCHED_PID + sleep "$STAGGER" + launch_player 2 "$p2" "$P2_VIDPID"; pid2=$LAUNCHED_PID + + # Place windows AFTER the splash (the game resizes itself during it). Fire a few + # times so both instances get caught once settled. + ( sleep "$SPLASH_WAIT"; cmd_tile >/dev/null 2>&1 + sleep 10; cmd_tile >/dev/null 2>&1 + sleep 15; cmd_tile >/dev/null 2>&1 ) & + + say "" + ok "Both instances launching (pids $pid1 / $pid2)." + say "In-game: ${c_grn}Player 1${c_rst} → Play → host over LAN. ${c_grn}Player 2${c_rst} → Play → Join → pick the LAN game." + local where="their monitors"; [ "$DISPLAY_MODE" = split ] && where="their halves" + say "${c_dim}Windows settle onto $where ~${SPLASH_WAIT}s in (after the splash)." + say "Re-place any time with: ./bl2-splitscreen.sh tile${c_rst}" + say ""; say "Press Ctrl-C to kill both instances." + trap 'kill "$pid1" "$pid2" 2>/dev/null' INT TERM + wait "$pid1" "$pid2" +} + +# ------------------------------------------------------------------ clean --- +cmd_clean() { say "Removing generated copies/saves (real game untouched)..."; rm -rf "$BASE/p1" "$BASE/p2"; ok "Done."; } + +case "${1:-}" in + 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 ;; +esac diff --git a/goldberg/.gitkeep b/goldberg/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/p1/.gitkeep b/p1/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/p2/.gitkeep b/p2/.gitkeep new file mode 100644 index 0000000..e69de29