]>
| Commit | Line | Data |
|---|---|---|
| cb05cf8e MV |
1 | #!/usr/bin/env bash |
| 2 | # --------------------------------------------------------------------------- | |
| 3 | # Borderlands 2 - two-player split screen on Linux | |
| 4 | # | |
| 5 | # Borderlands 2 has NO native PC split screen, so this launches TWO independent | |
| 6 | # instances that connect over Goldberg's emulated Steam LAN. Each instance gets | |
| 7 | # its own save, its own Goldberg identity, one controller, and half the screen | |
| 8 | # (gamescope), auto-tiled side-by-side on KDE Plasma (KWin). | |
| 9 | # | |
| 10 | # Two build layouts are supported and AUTO-DETECTED: | |
| 11 | # * native - the Aspyr native Linux port (ELF `Borderlands2`) -> no Proton | |
| 12 | # * proton - the Windows build (`Binaries/Win32/Borderlands2.exe`) -> umu/Proton | |
| 13 | # Force one with: MODE=native|proton ./bl2-splitscreen.sh ... | |
| 14 | # | |
| 15 | # Usage: | |
| 16 | # ./bl2-splitscreen.sh check # verify prerequisites for the detected mode | |
| 17 | # ./bl2-splitscreen.sh setup # build the two patched game copies | |
| 18 | # ./bl2-splitscreen.sh run # launch both halves + tile them | |
| 19 | # ./bl2-splitscreen.sh tile # (re)tile the two windows on KDE | |
| 20 | # ./bl2-splitscreen.sh clean # remove generated copies (not the game) | |
| 21 | # --------------------------------------------------------------------------- | |
| 22 | set -uo pipefail | |
| 23 | ||
| 24 | # ============================ CONFIG ======================================= | |
| 25 | GAME_DIR="/home/max/.local/share/Steam/steamapps/common/Borderlands 2" | |
| 26 | APPID=49520 | |
| 27 | BASE="/home/max/Games/bl2-split" | |
| 28 | ||
| 29 | # --- Display geometry (per-player render size GW/GH and window position GX/GY) -- | |
| 30 | # Monitors are read from KDE in LOGICAL px as "X,Y WxH", sorted left-to-right. | |
| 31 | _mons=$(kscreen-doctor -o 2>/dev/null | sed 's/\x1b\[[0-9;]*m//g' \ | |
| 32 | | awk '/^Output:/{e=0} /enabled/{e=1} /Geometry:/&&e{print $2,$3}' | sort -t, -k1 -n) | |
| 33 | _mi=1 | |
| 34 | while read -r _pos _size; do | |
| 35 | [ -z "$_pos" ] && continue | |
| 36 | _MX[$_mi]=${_pos%,*}; _MY[$_mi]=${_pos#*,} | |
| 37 | _MW[$_mi]=${_size%x*}; _MH[$_mi]=${_size#*x} | |
| 38 | _mi=$((_mi+1)) | |
| 39 | done <<< "$_mons" | |
| 40 | MON_COUNT=$((_mi-1)) | |
| 41 | [ "${MON_COUNT:-0}" -ge 1 ] 2>/dev/null || { _MW[1]=2560; _MH[1]=1440; _MX[1]=0; _MY[1]=0; MON_COUNT=1; } | |
| 42 | ||
| 43 | # DISPLAY_MODE: | |
| 44 | # dual (default when >=2 monitors) -> each player gets a whole monitor, "fullscreen". | |
| 45 | # split (1 monitor) -> both on one monitor, side-by-side. Each half is 16:9 and | |
| 46 | # vertically centered, because a tall 8:9 window clips BL2's landscape UI, | |
| 47 | # and a full-width window would trip BL2 into exclusive fullscreen. | |
| 48 | if [ "$MON_COUNT" -ge 2 ]; then DISPLAY_MODE="${DISPLAY_MODE:-dual}"; else DISPLAY_MODE="${DISPLAY_MODE:-split}"; fi | |
| 49 | ||
| 50 | if [ "$DISPLAY_MODE" = dual ]; then | |
| 51 | GW[1]=${_MW[1]}; GH[1]=${_MH[1]}; GX[1]=${_MX[1]}; GY[1]=${_MY[1]} | |
| 52 | GW[2]=${_MW[2]}; GH[2]=${_MH[2]}; GX[2]=${_MX[2]}; GY[2]=${_MY[2]} | |
| 53 | else | |
| 54 | _hw=$(( ${_MW[1]} / 2 )); _hh=$(( _hw * 9 / 16 )) | |
| 55 | [ "$_hh" -gt "${_MH[1]}" ] && _hh=${_MH[1]} | |
| 56 | _wy=$(( ${_MY[1]} + (${_MH[1]} - _hh) / 2 )) | |
| 57 | GW[1]=$_hw; GH[1]=$_hh; GX[1]=${_MX[1]}; GY[1]=$_wy | |
| 58 | GW[2]=$_hw; GH[2]=$_hh; GX[2]=$(( ${_MX[1]} + _hw )); GY[2]=$_wy | |
| 59 | fi | |
| 60 | ||
| 61 | # Controllers, matched by STABLE /dev/input/by-id names (survive replug/reboot). | |
| 62 | P1_PAD_GLOB="/dev/input/by-id/*Microsoft_Controller_*-event-joystick" # Xbox Series S|X (wired) | |
| 63 | P2_PAD_GLOB="/dev/input/by-id/*Wireless_Receiver*-event-joystick" # Xbox 360 Wireless Receiver | |
| 64 | P1_VIDPID="0x045e/0x0b12" # Xbox Series S|X (SDL hint; used in proton mode) | |
| 65 | P2_VIDPID="0x045e/0x0719" # Xbox 360 Wireless Receiver | |
| 66 | ||
| 67 | # Controller isolation: "bwrap" (mask other pad's /dev nodes; backend-agnostic) | |
| 68 | # or "sdl" (VID/PID hint; only works for SDL games). Default depends on MODE: | |
| 69 | # native -> bwrap (the Aspyr binary is NOT SDL; the hint does nothing) | |
| 70 | # proton -> sdl (Proton is SDL-based; avoids bwrap<->pressure-vessel nesting) | |
| 71 | ISOLATION="${ISOLATION:-}" | |
| 72 | ||
| 73 | # Proton build for umu (proton mode). Empty = umu auto-selects/downloads UMU-Proton. | |
| 74 | PROTONPATH="${PROTONPATH:-}" | |
| 75 | ||
| 76 | # Native mode: the 2012 Aspyr binary must run inside the Steam scout runtime (the | |
| 77 | # old libs it was built against) with the CLASSIC Mr_Goldberg emu. gbe_fork (both | |
| 78 | # experimental and regular) segfaults this binary; classic goldberg works. | |
| 79 | NATIVE_RUNTIME="${NATIVE_RUNTIME:-/home/max/.steam/steam/ubuntu12_32/steam-runtime/run.sh}" | |
| 80 | ||
| 81 | # Seconds to wait before auto-tiling, i.e. how long the intro logos/movies take to | |
| 82 | # clear on your machine. Bump it if the windows snap into place too early (while | |
| 83 | # still on the splash). You can always re-run `./bl2-splitscreen.sh tile`. | |
| 84 | SPLASH_WAIT="${SPLASH_WAIT:-45}" | |
| 85 | ||
| 86 | # Seconds to wait after launching Player 1 before launching Player 2. Giving P1 | |
| 87 | # time to reach the menu and start hosting reduces a co-op-startup race that can | |
| 88 | # crash the second (fragile Aspyr) instance. | |
| 89 | STAGGER="${STAGGER:-25}" | |
| 90 | # =========================================================================== | |
| 91 | ||
| 92 | c_red=$'\e[31m'; c_grn=$'\e[32m'; c_yel=$'\e[33m'; c_dim=$'\e[2m'; c_rst=$'\e[0m' | |
| 93 | say() { printf '%s\n' "$*"; } | |
| 94 | ok() { printf '%s✓%s %s\n' "$c_grn" "$c_rst" "$*"; } | |
| 95 | warn() { printf '%s!%s %s\n' "$c_yel" "$c_rst" "$*"; } | |
| 96 | die() { printf '%s✗ %s%s\n' "$c_red" "$*" "$c_rst" >&2; exit 1; } | |
| 97 | ||
| 98 | # -------------------------------------------------------- mode detection --- | |
| 99 | WIN_EXE_REL="Binaries/Win32/Borderlands2.exe" | |
| 100 | NATIVE_BIN="Borderlands2" | |
| 101 | detect_mode() { | |
| 102 | if [ -n "${MODE:-}" ]; then echo "$MODE"; return; fi | |
| 103 | if [ -f "$GAME_DIR/$WIN_EXE_REL" ]; then echo proton | |
| 104 | elif [ -x "$GAME_DIR/$NATIVE_BIN" ]; then echo native | |
| 105 | else echo unknown; fi | |
| 106 | } | |
| 107 | MODE="$(detect_mode)" | |
| 108 | [ "$MODE" = unknown ] && die "Cannot find BL2 - neither $NATIVE_BIN nor $WIN_EXE_REL under $GAME_DIR" | |
| 109 | if [ -z "$ISOLATION" ]; then ISOLATION=bwrap; fi # device-masking works for both modes | |
| 948cce3b MV |
110 | GOLDBERG_SO="$BASE/goldberg/libsteam_api.so" # native (classic Mr_Goldberg) |
| 111 | GOLDBERG_DLL="$BASE/goldberg/steam_api.dll" # proton (gbe_fork) | |
| 112 | ||
| 113 | # Goldberg emulator downloads (NOT committed to git - fetched by `fetch`). Pinned | |
| 114 | # to known-good releases; override via env if these ever move. | |
| 115 | GBE_FORK_TAG="${GBE_FORK_TAG:-release-2026_05_30}" | |
| 116 | GBE_FORK_WIN_URL="${GBE_FORK_WIN_URL:-https://github.com/Detanup01/gbe_fork/releases/download/$GBE_FORK_TAG/emu-win-release.7z}" | |
| 117 | CLASSIC_GOLDBERG_URL="${CLASSIC_GOLDBERG_URL:-https://gitlab.com/Mr_Goldberg/goldberg_emulator/uploads/2524331e488ec6399c396cf48bbe9903/Goldberg_Lan_Steam_Emu_v0.2.5.zip}" | |
| cb05cf8e MV |
118 | |
| 119 | resolve_pad() { local m; for m in $1; do [ -e "$m" ] && { readlink -f "$m"; return; }; done; } | |
| 120 | ||
| 948cce3b MV |
121 | # --------------------------------------------------------------- fetch deps - |
| 122 | # Download the Goldberg emulator binaries (kept out of git). Proton needs the | |
| 123 | # gbe_fork Windows steam_api.dll; native needs the classic Mr_Goldberg Linux .so. | |
| 124 | cmd_fetch() { # downloads the Goldberg binary the detected MODE needs | |
| 125 | command -v bsdtar >/dev/null || die "bsdtar missing (sudo pacman -S libarchive)" | |
| 126 | command -v curl >/dev/null || die "curl missing (sudo pacman -S curl)" | |
| 127 | mkdir -p "$BASE/goldberg" | |
| 128 | local tmp; tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' RETURN | |
| 129 | ||
| 130 | if [ "$MODE" = proton ]; then | |
| 131 | if [ -f "$GOLDBERG_DLL" ]; then ok "gbe_fork steam_api.dll already present"; return; fi | |
| 132 | say "Downloading gbe_fork (Windows) $GBE_FORK_TAG ..." | |
| 133 | curl -fsSL -o "$tmp/win.7z" "$GBE_FORK_WIN_URL" || die "download failed: $GBE_FORK_WIN_URL" | |
| 134 | bsdtar -xf "$tmp/win.7z" -C "$tmp" release/experimental/x86/steam_api.dll \ | |
| 135 | || die "could not extract steam_api.dll from the archive" | |
| 136 | cp "$tmp/release/experimental/x86/steam_api.dll" "$GOLDBERG_DLL" | |
| 137 | file "$GOLDBERG_DLL" | grep -qi "PE32 .*i386" && ok "steam_api.dll (proton) fetched" \ | |
| 138 | || die "fetched steam_api.dll is not the expected 32-bit PE" | |
| 139 | else | |
| 140 | if [ -f "$GOLDBERG_SO" ]; then ok "classic goldberg libsteam_api.so already present"; return; fi | |
| 141 | say "Downloading classic Mr_Goldberg (Linux) ..." | |
| 142 | curl -fsSL -o "$tmp/lin.zip" "$CLASSIC_GOLDBERG_URL" || die "download failed: $CLASSIC_GOLDBERG_URL" | |
| 143 | bsdtar -xf "$tmp/lin.zip" -C "$tmp" linux/x86/libsteam_api.so \ | |
| 144 | || die "could not extract libsteam_api.so from the archive" | |
| 145 | cp "$tmp/linux/x86/libsteam_api.so" "$GOLDBERG_SO" | |
| 146 | file "$GOLDBERG_SO" | grep -q "ELF 32-bit" && ok "libsteam_api.so (native) fetched" \ | |
| 147 | || die "fetched libsteam_api.so is not the expected 32-bit ELF" | |
| 148 | fi | |
| 149 | } | |
| 150 | ||
| cb05cf8e MV |
151 | # ------------------------------------------------------------------ check --- |
| 152 | cmd_check() { | |
| 153 | local fail=0 | |
| 154 | say "== Mode: ${c_grn}$MODE${c_rst} (isolation=$ISOLATION) ==" | |
| 155 | say ""; say "== Prerequisites ==" | |
| 156 | command -v qdbus6 >/dev/null && ok "qdbus6 (KWin tiling)" || warn "qdbus6 missing - auto-tiling disabled ${c_dim}(sudo pacman -S qt6-tools)${c_rst}" | |
| 157 | [ "$ISOLATION" = bwrap ] && { command -v bwrap >/dev/null && ok "bwrap" || { warn "bwrap missing"; fail=1; }; } | |
| 158 | if [ "$MODE" = proton ]; then | |
| 159 | command -v gamescope >/dev/null && ok "gamescope" || { warn "gamescope missing ${c_dim}(sudo pacman -S gamescope)${c_rst}"; fail=1; } | |
| 160 | command -v umu-run >/dev/null && ok "umu-run" || { warn "umu-run missing ${c_dim}(sudo pacman -S umu-launcher)${c_rst}"; fail=1; } | |
| 161 | else | |
| 162 | [ -f "$NATIVE_RUNTIME" ] && ok "Steam scout runtime" || { warn "scout runtime missing: $NATIVE_RUNTIME"; fail=1; } | |
| 163 | command -v strings >/dev/null && ok "strings (interface extraction)" || { warn "strings missing ${c_dim}(sudo pacman -S binutils)${c_rst}"; fail=1; } | |
| 164 | fi | |
| 165 | ||
| 166 | say ""; say "== Game ==" | |
| 167 | if [ "$MODE" = native ]; then | |
| 168 | [ -x "$GAME_DIR/$NATIVE_BIN" ] && file "$GAME_DIR/$NATIVE_BIN" | grep -q ELF \ | |
| 169 | && ok "native ELF: $NATIVE_BIN" || { warn "native binary missing"; fail=1; } | |
| 170 | else | |
| 171 | [ -f "$GAME_DIR/$WIN_EXE_REL" ] && ok "Borderlands2.exe" || { warn "exe missing: $WIN_EXE_REL"; fail=1; } | |
| 172 | fi | |
| 173 | ||
| 174 | say ""; say "== Goldberg ==" | |
| 175 | if [ "$MODE" = native ]; then | |
| 176 | if [ -f "$GOLDBERG_SO" ] && file "$GOLDBERG_SO" | grep -q "ELF 32-bit"; then ok "libsteam_api.so (classic Mr_Goldberg, 32-bit ELF)" | |
| 177 | else warn "missing/invalid $GOLDBERG_SO (need classic Mr_Goldberg linux/x86 libsteam_api.so)"; fail=1; fi | |
| 178 | else | |
| 179 | if [ -f "$GOLDBERG_DLL" ] && file "$GOLDBERG_DLL" | grep -qi "PE32 .*i386"; then ok "steam_api.dll (PE32 i386)" | |
| 180 | else warn "missing/invalid $GOLDBERG_DLL (need Windows x86 gbe_fork experimental)"; fail=1; fi | |
| 181 | fi | |
| 182 | ||
| 183 | say ""; say "== Controllers ==" | |
| 184 | local p1 p2 padfail=0; p1=$(resolve_pad "$P1_PAD_GLOB"); p2=$(resolve_pad "$P2_PAD_GLOB") | |
| 185 | [ -n "$p1" ] && ok "Player 1 pad -> $p1" || { warn "Player 1 pad not connected"; padfail=1; } | |
| 186 | [ -n "$p2" ] && ok "Player 2 pad -> $p2" || { warn "Player 2 pad not connected (turn it on)"; padfail=1; } | |
| 187 | # Controllers only matter for 'run'; setup passes REQUIRE_PADS=0. | |
| 188 | [ "${REQUIRE_PADS:-1}" = 1 ] && [ "$padfail" = 1 ] && fail=1 | |
| 189 | ||
| 190 | say "" | |
| 191 | [ "$fail" = 0 ] && ok "All good - run: ./bl2-splitscreen.sh setup" || warn "Fix the above, then re-run check." | |
| 192 | return $fail | |
| 193 | } | |
| 194 | ||
| 195 | # ------------------------------------------------------------------ setup --- | |
| 196 | write_goldberg_settings() { # $1=dir $2=account $3=steamid | |
| 197 | local ss="$1/steam_settings"; mkdir -p "$ss" | |
| 198 | printf '%s\n' "$APPID" > "$1/steam_appid.txt" | |
| 199 | cat > "$ss/configs.user.ini" <<EOF | |
| 200 | [user::general] | |
| 201 | account_name=$2 | |
| 202 | account_steamid=$3 | |
| 203 | language=english | |
| 204 | ip_country=US | |
| 205 | EOF | |
| 206 | printf '%s\n' "$2" > "$ss/account_name.txt" | |
| 207 | printf '%s\n' "$2" > "$ss/force_account_name.txt" # classic goldberg name override | |
| 208 | printf '%s\n' "$3" > "$ss/force_steamid.txt" # classic goldberg id override | |
| 209 | printf '%s\n' "$3" > "$ss/user_steam_id.txt" | |
| 210 | # Two same-host instances (each in its own Proton container) may not reach each | |
| 211 | # other via UDP broadcast, so co-op hangs at "Creating online session". Tell the | |
| 212 | # emu to send discovery directly to localhost so they always find each other. | |
| 213 | printf '127.0.0.1\n' > "$ss/custom_broadcasts.txt" | |
| 948cce3b MV |
214 | # BL2 stalls on "Creating online session" waiting on (unreachable) online Steam |
| 215 | # servers; offline mode makes the emu report Steam offline so it skips that wait | |
| 216 | # and goes to LAN. LAN co-op still works. Remove this file if it ever breaks join. | |
| 217 | cat > "$ss/configs.main.ini" <<EOF | |
| 218 | [main::connectivity] | |
| 219 | offline=1 | |
| 220 | EOF | |
| cb05cf8e MV |
221 | } |
| 222 | ||
| 223 | # Old games hard-crash if the emu returns interfaces they don't expect. Extract | |
| 224 | # the exact interface versions from the game's ORIGINAL Steam lib. | |
| 225 | extract_interfaces() { # $1=steam_settings dir $2=path to original steam_api lib | |
| 226 | strings -n 5 "$2" 2>/dev/null | grep -E \ | |
| 227 | '^(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}$' \ | |
| 228 | | sort -u > "$1/steam_interfaces.txt" | |
| 229 | } | |
| 230 | ||
| 231 | build_player() { # $1=n $2=account $3=steamid | |
| 232 | local n="$1" acct="$2" sid="$3" pg="$BASE/p$1/game" | |
| 233 | say " [$n] symlink game copy -> $pg" | |
| 234 | rm -rf "$BASE/p$n/game"; mkdir -p "$BASE/p$n/home" | |
| 235 | cp -as "$GAME_DIR/." "$pg/" || die "cp -as failed (need GNU coreutils)" | |
| 236 | if [ "$MODE" = native ]; then | |
| 237 | rm -f "$pg/libsteam_api.so"; cp "$GOLDBERG_SO" "$pg/libsteam_api.so" | |
| 238 | write_goldberg_settings "$pg" "$acct" "$sid" | |
| 239 | extract_interfaces "$pg/steam_settings" "$GAME_DIR/libsteam_api.so" | |
| 240 | [ -s "$pg/steam_settings/steam_interfaces.txt" ] || warn " [$n] no interfaces extracted (game may crash)" | |
| 241 | else | |
| 242 | local w="$pg/Binaries/Win32" | |
| 243 | # Wine resolves a SYMLINKED exe to its target directory and loads DLLs from | |
| 244 | # THERE (the real steam_api.dll -> "Steam must be running"), bypassing our | |
| 245 | # Goldberg dll. Make the exes real copies so DLL search happens in our dir. | |
| 246 | local e real | |
| 247 | for e in Borderlands2.exe Launcher.exe; do | |
| 248 | [ -L "$w/$e" ] && { real=$(readlink -f "$w/$e"); cp --remove-destination "$real" "$w/$e"; } | |
| 249 | done | |
| 250 | # keep a copy of the original Windows lib before overwriting, for interfaces | |
| 251 | [ -f "$w/steam_api.dll.orig" ] || cp -L "$w/steam_api.dll" "$w/steam_api.dll.orig" 2>/dev/null | |
| 252 | write_goldberg_settings "$w" "$acct" "$sid" | |
| 253 | extract_interfaces "$w/steam_settings" "$w/steam_api.dll.orig" | |
| 254 | rm -f "$w/steam_api.dll"; cp "$GOLDBERG_DLL" "$w/steam_api.dll" | |
| 255 | mkdir -p "$BASE/p$n/prefix" | |
| 256 | fi | |
| 257 | ok " [$n] account '$acct' (steamid $sid)" | |
| 258 | } | |
| 259 | ||
| 260 | cmd_setup() { | |
| 948cce3b | 261 | cmd_fetch # download Goldberg if not present |
| cb05cf8e MV |
262 | REQUIRE_PADS=0 cmd_check || die "check failed - resolve the above before setup" |
| 263 | say ""; say "== Building player copies ($MODE) ==" | |
| 264 | build_player 1 "Player1" "76561197960287930" | |
| 265 | build_player 2 "Player2" "76561197960287931" | |
| 266 | say ""; ok "Setup complete. Launch with: ./bl2-splitscreen.sh run" | |
| 267 | } | |
| 268 | ||
| 269 | # -------------------------------------------------------------------- run --- | |
| 270 | # Pin the game to windowed half-screen in its own config, so it doesn't apply a | |
| 271 | # saved fullscreen mode after loading (which overrides -windowed and unstacks the | |
| 272 | # tiling). Section-aware: ResX/Fullscreen appear in several sections. | |
| 273 | patch_native_config() { # $1=player home $2=width $3=height | |
| 274 | local f | |
| 275 | f=$(find "$1" -ipath "*willowgame/config/willowengine.ini" 2>/dev/null | head -1) | |
| 276 | [ -f "$f" ] || return 0 | |
| 277 | # Config uses CRLF: strip trailing CR for matching, re-add it on output. | |
| 278 | awk -v W="$2" -v H="$3" ' | |
| 279 | { cr = (sub(/\r$/,"") ? "\r" : "") } | |
| 280 | /^\[/ { sec=$0 } | |
| 281 | { | |
| 282 | l=$0 | |
| 283 | if (sec=="[WinDrv.WindowsClient]") { | |
| 284 | if (l ~ /^StartupFullscreen=/) l="StartupFullscreen=False" | |
| 285 | else if (l ~ /^StartupResolutionX=/) l="StartupResolutionX=" W | |
| 286 | else if (l ~ /^StartupResolutionY=/) l="StartupResolutionY=" H | |
| 287 | } else if (sec=="[SystemSettings]") { | |
| 288 | if (l ~ /^Fullscreen=/) l="Fullscreen=False" | |
| 289 | else if (l ~ /^WindowedFullscreen=/) l="WindowedFullscreen=False" | |
| 290 | else if (l ~ /^ResX=/) l="ResX=" W | |
| 291 | else if (l ~ /^ResY=/) l="ResY=" H | |
| 292 | } else if (sec=="[FullScreenMovie]") { | |
| 293 | if (l ~ /^StartupMovies=/) next # drop the intro logos | |
| 294 | else if (l ~ /^bForceNoMovies=/) l="bForceNoMovies=TRUE" | |
| 295 | } | |
| 296 | printf "%s%s\n", l, cr | |
| 297 | }' "$f" > "$f.tmp" && mv "$f.tmp" "$f" | |
| 298 | } | |
| 299 | ||
| 300 | # Proton keeps its config inside the Wine prefix; drop the intro movies there so | |
| 301 | # the game goes straight to the menu (resolution is handled by -ResX/-ResY args). | |
| 302 | patch_proton_config() { # $1 = prefix dir | |
| 303 | local f | |
| 304 | f=$(find "$1/drive_c/users" -ipath "*Borderlands 2*Config/WillowEngine.ini" 2>/dev/null | head -1) | |
| 305 | [ -f "$f" ] || return 0 | |
| 306 | awk ' | |
| 307 | { cr = (sub(/\r$/,"") ? "\r" : "") } | |
| 308 | /^\[/ { sec=$0 } | |
| 309 | { l=$0 | |
| 310 | if (sec=="[FullScreenMovie]") { if (l ~ /^StartupMovies=/) next; else if (l ~ /^bForceNoMovies=/) l="bForceNoMovies=TRUE" } | |
| 311 | printf "%s%s\n", l, cr }' "$f" > "$f.tmp" && mv "$f.tmp" "$f" | |
| 312 | } | |
| 313 | ||
| 314 | mask_other_pad_args() { # bwrap args hiding every pad except kept node $1 | |
| 315 | local keep="$1" node other g m | |
| 316 | for g in $P1_PAD_GLOB $P2_PAD_GLOB; do for m in $g; do | |
| 317 | [ -e "$m" ] || continue | |
| 318 | node=$(readlink -f "$m"); [ "$node" = "$keep" ] && continue | |
| 319 | printf -- '--bind /dev/null %s ' "$node" | |
| 320 | other="${m%-event-joystick}-joystick" | |
| 321 | [ -e "$other" ] && printf -- '--bind /dev/null %s ' "$(readlink -f "$other")" | |
| 322 | done; done | |
| 323 | } | |
| 324 | ||
| 325 | launch_player() { # $1=n $2=pad-node $3=vidpid -> sets LAUNCHED_PID | |
| 326 | local n="$1" pad="$2" vidpid="$3" pg="$BASE/p$1/game" wd | |
| 327 | local rw="${GW[$n]}" rh="${GH[$n]}" # this player's render size | |
| 328 | local -a env cmd | |
| 329 | # No gamescope in either mode: it doesn't pass raw controllers to nested clients, | |
| 330 | # so the game sees no pad. Run the game windowed and let KWin place it. | |
| 331 | if [ "$MODE" = native ]; then | |
| 332 | local home="$BASE/p$n/home"; wd="$pg" | |
| 333 | patch_native_config "$home" "$rw" "$rh" # pin windowed size in the config | |
| 334 | env=( "HOME=$home" "XDG_DATA_HOME=$home/.local/share" "XDG_CONFIG_HOME=$home/.config" | |
| 335 | "LD_LIBRARY_PATH=$pg:${LD_LIBRARY_PATH:-}" | |
| 336 | "SteamAppId=$APPID" "SteamGameId=$APPID" | |
| 337 | "SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS=1" ) | |
| 338 | cmd=( "$NATIVE_RUNTIME" "./$NATIVE_BIN" -windowed "-ResX=$rw" "-ResY=$rh" ) | |
| 339 | else | |
| 340 | wd="$pg/Binaries/Win32" | |
| 341 | patch_proton_config "$BASE/p$n/prefix" # skip intro movies (if prefix exists) | |
| e02b2f88 MV |
342 | mkdir -p "$BASE/dxvk-cache" |
| 343 | env=( "WINEPREFIX=$BASE/p$n/prefix" "GAMEID=0" "STORE=none" | |
| 344 | "UMU_RUNTIME_UPDATE=0" # skip umu's startup update check | |
| 345 | # Shared D3D shader cache: the 2nd instance reuses shaders the 1st already | |
| 346 | # compiled instead of recompiling them while the 1st is using the GPU. | |
| 347 | "DXVK_STATE_CACHE_PATH=$BASE/dxvk-cache" ) | |
| cb05cf8e MV |
348 | # Only use the SDL VID/PID filter in "sdl" mode. With bwrap masking it is |
| 349 | # redundant AND can over-filter (it wrongly dropped the 360 pad), so skip it. | |
| 350 | [ "$ISOLATION" = sdl ] && env+=( "SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=$vidpid" ) | |
| 351 | [ -n "$PROTONPATH" ] && env+=( "PROTONPATH=$PROTONPATH" ) | |
| 352 | cmd=( umu-run "$pg/$WIN_EXE_REL" -windowed "-ResX=$rw" "-ResY=$rh" ) | |
| 353 | fi | |
| 354 | ||
| 355 | # bwrap (if used) masks the other pad's /dev/input nodes for this instance. | |
| 356 | local -a inner | |
| 357 | if [ "$ISOLATION" = bwrap ]; then | |
| 358 | local -a maskargs=(); read -ra maskargs < <(mask_other_pad_args "$pad") | |
| 359 | inner=( bwrap --dev-bind / / --die-with-parent --chdir "$wd" "${maskargs[@]}" -- "${cmd[@]}" ) | |
| 360 | else | |
| 361 | inner=( "${cmd[@]}" ) | |
| 362 | fi | |
| 363 | ||
| 364 | say "${c_dim}[$n] pad=$pad ${rw}x${rh} @ ${GX[$n]},${GY[$n]} log=$BASE/p$n/log${c_rst}" | |
| 365 | # Background in the CURRENT shell (not a $() subshell) so the job is a real | |
| 366 | # child that cmd_run can wait on; hand the PID back via a global. | |
| 367 | ( cd "$wd" || exit 1; exec env "${env[@]}" "${inner[@]}" ) >"$BASE/p$n/log" 2>&1 & | |
| 368 | LAUNCHED_PID=$! | |
| 369 | } | |
| 370 | ||
| 371 | # --------------------------------------------------------- KDE/KWin tiling -- | |
| 372 | # One-shot: place each BL2 window on its half in LOGICAL coordinates. Run AFTER | |
| 373 | # the splash movies so the game has settled at its configured size (tiling during | |
| 374 | # the splash fights the game and looks wrong). All geometry is logical px. | |
| 375 | cmd_tile() { | |
| 376 | command -v qdbus6 >/dev/null || { warn "qdbus6 not found - cannot auto-tile (drag windows manually)"; return 0; } | |
| 377 | # Left/right split, both windows vertically centered (WIN_Y). | |
| 378 | # Per-window geometry: window[0]=player1, window[1]=player2 (launch order). | |
| 379 | local js; js="$(mktemp --suffix=.js)" | |
| 380 | cat > "$js" <<EOF | |
| 381 | (function(){ | |
| 382 | var geo=[{x:${GX[1]},y:${GY[1]},w:${GW[1]},h:${GH[1]}}, | |
| 383 | {x:${GX[2]},y:${GY[2]},w:${GW[2]},h:${GH[2]}}]; | |
| 384 | var l=(workspace.windowList?workspace.windowList():workspace.clientList()); | |
| 385 | var wins=l.filter(function(w){ | |
| 386 | var s=((w.resourceClass||"")+" "+(w.resourceName||"")+" "+(w.caption||"")).toLowerCase(); | |
| 387 | return s.indexOf("borderlands")>=0 || s.indexOf("steam_app")>=0; | |
| 388 | }); | |
| 389 | wins.sort(function(a,b){ return (""+a.internalId)<(""+b.internalId)?-1:1; }); | |
| 390 | for(var i=0;i<wins.length && i<2;i++){ | |
| 391 | var w=wins[i], g=geo[i]; | |
| 392 | try{w.fullScreen=false;}catch(e){} | |
| 393 | try{w.setMaximize(false,false);}catch(e){} | |
| 394 | try{w.noBorder=true;}catch(e){} | |
| 395 | w.frameGeometry={x:g.x,y:g.y,width:g.w,height:g.h}; | |
| 396 | } | |
| 397 | })(); | |
| 398 | EOF | |
| 399 | qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript "$js" bl2tile >/dev/null 2>&1 | |
| 400 | qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.start >/dev/null 2>&1 | |
| 401 | qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.unloadScript bl2tile >/dev/null 2>&1 | |
| 402 | rm -f "$js" | |
| 403 | ok "Placed windows (${DISPLAY_MODE}: ${GW[1]}x${GH[1]} + ${GW[2]}x${GH[2]})." | |
| 404 | } | |
| 405 | ||
| 406 | cmd_run() { | |
| 407 | [ -d "$BASE/p1/game" ] && [ -d "$BASE/p2/game" ] || die "run setup first" | |
| 408 | local p1 p2; p1=$(resolve_pad "$P1_PAD_GLOB"); p2=$(resolve_pad "$P2_PAD_GLOB") | |
| 409 | [ -n "$p1" ] || die "Player 1 controller not connected" | |
| 410 | [ -n "$p2" ] || die "Player 2 controller not connected (turn it on)" | |
| 411 | ||
| 412 | # Steam Input (active while Steam runs) injects VIRTUAL gamepads that SDL grabs | |
| 413 | # instead of the real pads, which defeats per-instance controller isolation. | |
| 414 | # Goldberg replaces Steam, so it isn't needed while playing. | |
| 415 | if pgrep -x steam >/dev/null; then | |
| 416 | warn "Steam is running - Steam Input's virtual gamepads can break per-player" | |
| 417 | warn "controller isolation. If a pad drives the wrong window, QUIT STEAM" | |
| 418 | warn "(Goldberg replaces it). Continuing in 5s..." | |
| 419 | sleep 5 | |
| 420 | fi | |
| 421 | ||
| 422 | say "== Launching Borderlands 2 (mode=$MODE, isolation=$ISOLATION, display=$DISPLAY_MODE) ==" | |
| 423 | 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}" | |
| 424 | local pid1 pid2 | |
| 425 | launch_player 1 "$p1" "$P1_VIDPID"; pid1=$LAUNCHED_PID | |
| 426 | sleep "$STAGGER" | |
| 427 | launch_player 2 "$p2" "$P2_VIDPID"; pid2=$LAUNCHED_PID | |
| 428 | ||
| 429 | # Place windows AFTER the splash (the game resizes itself during it). Fire a few | |
| 430 | # times so both instances get caught once settled. | |
| 431 | ( sleep "$SPLASH_WAIT"; cmd_tile >/dev/null 2>&1 | |
| 432 | sleep 10; cmd_tile >/dev/null 2>&1 | |
| 433 | sleep 15; cmd_tile >/dev/null 2>&1 ) & | |
| 434 | ||
| 435 | say "" | |
| 436 | ok "Both instances launching (pids $pid1 / $pid2)." | |
| 437 | 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." | |
| 438 | local where="their monitors"; [ "$DISPLAY_MODE" = split ] && where="their halves" | |
| 439 | say "${c_dim}Windows settle onto $where ~${SPLASH_WAIT}s in (after the splash)." | |
| 440 | say "Re-place any time with: ./bl2-splitscreen.sh tile${c_rst}" | |
| 441 | say ""; say "Press Ctrl-C to kill both instances." | |
| e02b2f88 MV |
442 | # Killing $pid1/$pid2 only hits the top of each tree; the game runs deeper inside |
| 443 | # umu/pressure-vessel and survives. kill_instances tears down the whole tree. | |
| 444 | trap 'echo; kill_instances; exit 130' INT TERM | |
| cb05cf8e MV |
445 | wait "$pid1" "$pid2" |
| 446 | } | |
| 447 | ||
| e02b2f88 MV |
448 | # ------------------------------------------------------------------ kill ---- |
| 449 | # Terminate every Borderlands 2 process we launched (game exe + umu/Proton/Wine | |
| 450 | # tree for our prefixes), SIGTERM first then SIGKILL. Scoped to our BASE dir so it | |
| 451 | # won't touch unrelated Wine apps. | |
| 452 | kill_instances() { | |
| 453 | local sig | |
| 454 | for sig in TERM KILL; do | |
| 455 | pkill -"$sig" -f 'Borderlands2\.exe' 2>/dev/null # proton game | |
| 456 | pkill -"$sig" -x 'Borderlands2' 2>/dev/null # native game | |
| 457 | pkill -"$sig" -f "$BASE/p[12]/" 2>/dev/null # umu/proton/wine for our copies+prefixes | |
| 458 | [ "$sig" = TERM ] && sleep 2 | |
| 459 | done | |
| 460 | } | |
| 461 | ||
| 462 | cmd_kill() { | |
| 463 | # Match the process NAME (comm) as a substring, not the full cmdline - otherwise | |
| 464 | # helper shells that merely have the game path in their args get counted. This | |
| 465 | # matches "Borderlands2.ex" (Proton, 15-char truncated) and "Borderlands2". | |
| 466 | pgrep 'Borderlands2' >/dev/null || { ok "No Borderlands 2 instances running."; return 0; } | |
| 467 | say "Killing Borderlands 2 instances..." | |
| 468 | kill_instances | |
| 469 | local left; left=$(pgrep -c 'Borderlands2' 2>/dev/null || echo 0) | |
| 470 | [ "$left" = 0 ] && ok "All instances killed." || warn "$left process(es) still lingering." | |
| 471 | } | |
| 472 | ||
| cb05cf8e | 473 | # ------------------------------------------------------------------ clean --- |
| e02b2f88 | 474 | cmd_clean() { cmd_kill; say "Removing generated copies/saves (real game untouched)..."; rm -rf "$BASE/p1" "$BASE/p2"; ok "Done."; } |
| cb05cf8e MV |
475 | |
| 476 | case "${1:-}" in | |
| 948cce3b | 477 | fetch) cmd_fetch ;; |
| cb05cf8e MV |
478 | check) cmd_check ;; |
| 479 | setup) cmd_setup ;; | |
| 480 | run) cmd_run ;; | |
| 481 | tile) cmd_tile ;; | |
| e02b2f88 | 482 | kill) cmd_kill ;; |
| cb05cf8e | 483 | clean) cmd_clean ;; |
| e02b2f88 | 484 | *) say "Usage: $0 {fetch|check|setup|run|tile|kill|clean} (MODE=native|proton to force)"; exit 1 ;; |
| cb05cf8e | 485 | esac |