]> vilimpoc.org git repositories - bl2-split/blob - bl2-splitscreen.sh
86c35170c02d0278c60cfd5ad71e95793a2772ed
[bl2-split] / bl2-splitscreen.sh
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 after launch before the windows are moved onto their screens (long enough
82 # for both to load past the resize-y splash). You can always re-run `.../tile`.
83 SPLASH_WAIT="${SPLASH_WAIT:-30}"
84
85 # Seconds between launching Player 1 and Player 2. 0 = both at once (this machine is
86 # fast enough). Native mode can be crash-prone launching both together - set e.g.
87 # STAGGER=25 there if the 2nd instance dies on startup.
88 STAGGER="${STAGGER:-0}"
89 # ===========================================================================
90
91 c_red=$'\e[31m'; c_grn=$'\e[32m'; c_yel=$'\e[33m'; c_dim=$'\e[2m'; c_rst=$'\e[0m'
92 say()  { printf '%s\n' "$*"; }
93 ok()   { printf '%s✓%s %s\n' "$c_grn" "$c_rst" "$*"; }
94 warn() { printf '%s!%s %s\n' "$c_yel" "$c_rst" "$*"; }
95 die()  { printf '%s✗ %s%s\n' "$c_red" "$*" "$c_rst" >&2; exit 1; }
96 # Loud, unmissable, bold-bright-yellow banner. Each arg is one line (keep it CAPS).
97 banner() {
98   local b=$'\e[1;93m' r=$'\e[0m' bar="############################################################" l
99   printf '\n%s%s%s\n' "$b" "$bar" "$r"
100   for l in "$@"; do printf '%s##  %-54s##%s\n' "$b" "$l" "$r"; done
101   printf '%s%s%s\n\n' "$b" "$bar" "$r"
102 }
103
104 # -------------------------------------------------------- mode detection ---
105 WIN_EXE_REL="Binaries/Win32/Borderlands2.exe"
106 NATIVE_BIN="Borderlands2"
107 detect_mode() {
108   if [ -n "${MODE:-}" ]; then echo "$MODE"; return; fi
109   if [ -f "$GAME_DIR/$WIN_EXE_REL" ]; then echo proton
110   elif [ -x "$GAME_DIR/$NATIVE_BIN" ]; then echo native
111   else echo unknown; fi
112 }
113 MODE="$(detect_mode)"
114 [ "$MODE" = unknown ] && die "Cannot find BL2 - neither $NATIVE_BIN nor $WIN_EXE_REL under $GAME_DIR"
115 if [ -z "$ISOLATION" ]; then ISOLATION=bwrap; fi   # device-masking works for both modes
116 GOLDBERG_SO="$BASE/goldberg/libsteam_api.so"    # native (classic Mr_Goldberg)
117 GOLDBERG_DLL="$BASE/goldberg/steam_api.dll"     # proton (gbe_fork)
118
119 # Goldberg emulator downloads (NOT committed to git - fetched by `fetch`). Pinned
120 # to known-good releases; override via env if these ever move.
121 GBE_FORK_TAG="${GBE_FORK_TAG:-release-2026_05_30}"
122 GBE_FORK_WIN_URL="${GBE_FORK_WIN_URL:-https://github.com/Detanup01/gbe_fork/releases/download/$GBE_FORK_TAG/emu-win-release.7z}"
123 CLASSIC_GOLDBERG_URL="${CLASSIC_GOLDBERG_URL:-https://gitlab.com/Mr_Goldberg/goldberg_emulator/uploads/2524331e488ec6399c396cf48bbe9903/Goldberg_Lan_Steam_Emu_v0.2.5.zip}"
124
125 resolve_pad() { local m; for m in $1; do [ -e "$m" ] && { readlink -f "$m"; return; }; done; }
126
127 # --------------------------------------------------------------- fetch deps -
128 # Download the Goldberg emulator binaries (kept out of git). Proton needs the
129 # gbe_fork Windows steam_api.dll; native needs the classic Mr_Goldberg Linux .so.
130 cmd_fetch() {  # downloads the Goldberg binary the detected MODE needs
131   command -v bsdtar >/dev/null || die "bsdtar missing (sudo pacman -S libarchive)"
132   command -v curl   >/dev/null || die "curl missing (sudo pacman -S curl)"
133   mkdir -p "$BASE/goldberg"
134   local tmp; tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' RETURN
135
136   if [ "$MODE" = proton ]; then
137     if [ -f "$GOLDBERG_DLL" ]; then ok "gbe_fork steam_api.dll already present"; return; fi
138     say "Downloading gbe_fork (Windows) $GBE_FORK_TAG ..."
139     curl -fsSL -o "$tmp/win.7z" "$GBE_FORK_WIN_URL" || die "download failed: $GBE_FORK_WIN_URL"
140     bsdtar -xf "$tmp/win.7z" -C "$tmp" release/experimental/x86/steam_api.dll \
141       || die "could not extract steam_api.dll from the archive"
142     cp "$tmp/release/experimental/x86/steam_api.dll" "$GOLDBERG_DLL"
143     file "$GOLDBERG_DLL" | grep -qi "PE32 .*i386" && ok "steam_api.dll (proton) fetched" \
144       || die "fetched steam_api.dll is not the expected 32-bit PE"
145   else
146     if [ -f "$GOLDBERG_SO" ]; then ok "classic goldberg libsteam_api.so already present"; return; fi
147     say "Downloading classic Mr_Goldberg (Linux) ..."
148     curl -fsSL -o "$tmp/lin.zip" "$CLASSIC_GOLDBERG_URL" || die "download failed: $CLASSIC_GOLDBERG_URL"
149     bsdtar -xf "$tmp/lin.zip" -C "$tmp" linux/x86/libsteam_api.so \
150       || die "could not extract libsteam_api.so from the archive"
151     cp "$tmp/linux/x86/libsteam_api.so" "$GOLDBERG_SO"
152     file "$GOLDBERG_SO" | grep -q "ELF 32-bit" && ok "libsteam_api.so (native) fetched" \
153       || die "fetched libsteam_api.so is not the expected 32-bit ELF"
154   fi
155 }
156
157 # ------------------------------------------------------------------ check ---
158 cmd_check() {
159   local fail=0
160   say "== Mode: ${c_grn}$MODE${c_rst}  (isolation=$ISOLATION) =="
161   say ""; say "== Prerequisites =="
162   command -v qdbus6 >/dev/null && ok "qdbus6 (KWin tiling)" || warn "qdbus6 missing - auto-tiling disabled ${c_dim}(sudo pacman -S qt6-tools)${c_rst}"
163   [ "$ISOLATION" = bwrap ] && { command -v bwrap >/dev/null && ok "bwrap" || { warn "bwrap missing"; fail=1; }; }
164   if [ "$MODE" = proton ]; then
165     command -v gamescope >/dev/null && ok "gamescope" || { warn "gamescope missing ${c_dim}(sudo pacman -S gamescope)${c_rst}"; fail=1; }
166     command -v umu-run >/dev/null && ok "umu-run" || { warn "umu-run missing ${c_dim}(sudo pacman -S umu-launcher)${c_rst}"; fail=1; }
167   else
168     [ -f "$NATIVE_RUNTIME" ] && ok "Steam scout runtime" || { warn "scout runtime missing: $NATIVE_RUNTIME"; fail=1; }
169     command -v strings >/dev/null && ok "strings (interface extraction)" || { warn "strings missing ${c_dim}(sudo pacman -S binutils)${c_rst}"; fail=1; }
170   fi
171
172   say ""; say "== Game =="
173   if [ "$MODE" = native ]; then
174     [ -x "$GAME_DIR/$NATIVE_BIN" ] && file "$GAME_DIR/$NATIVE_BIN" | grep -q ELF \
175       && ok "native ELF: $NATIVE_BIN" || { warn "native binary missing"; fail=1; }
176   else
177     [ -f "$GAME_DIR/$WIN_EXE_REL" ] && ok "Borderlands2.exe" || { warn "exe missing: $WIN_EXE_REL"; fail=1; }
178   fi
179
180   say ""; say "== Goldberg =="
181   if [ "$MODE" = native ]; then
182     if [ -f "$GOLDBERG_SO" ] && file "$GOLDBERG_SO" | grep -q "ELF 32-bit"; then ok "libsteam_api.so (classic Mr_Goldberg, 32-bit ELF)"
183     else warn "missing/invalid $GOLDBERG_SO (need classic Mr_Goldberg linux/x86 libsteam_api.so)"; fail=1; fi
184   else
185     if [ -f "$GOLDBERG_DLL" ] && file "$GOLDBERG_DLL" | grep -qi "PE32 .*i386"; then ok "steam_api.dll (PE32 i386)"
186     else warn "missing/invalid $GOLDBERG_DLL (need Windows x86 gbe_fork experimental)"; fail=1; fi
187   fi
188
189   say ""; say "== Controllers =="
190   local p1 p2 padfail=0; p1=$(resolve_pad "$P1_PAD_GLOB"); p2=$(resolve_pad "$P2_PAD_GLOB")
191   [ -n "$p1" ] && ok "Player 1 pad -> $p1" || { warn "Player 1 pad not connected"; padfail=1; }
192   [ -n "$p2" ] && ok "Player 2 pad -> $p2" || { warn "Player 2 pad not connected (turn it on)"; padfail=1; }
193   # Controllers only matter for 'run'; setup passes REQUIRE_PADS=0.
194   [ "${REQUIRE_PADS:-1}" = 1 ] && [ "$padfail" = 1 ] && fail=1
195
196   say ""
197   [ "$fail" = 0 ] && ok "All good - run:  ./bl2-splitscreen.sh setup" || warn "Fix the above, then re-run check."
198   return $fail
199 }
200
201 # ------------------------------------------------------------------ setup ---
202 write_goldberg_settings() {  # $1=dir  $2=account  $3=steamid
203   local ss="$1/steam_settings"; mkdir -p "$ss"
204   printf '%s\n' "$APPID" > "$1/steam_appid.txt"
205   cat > "$ss/configs.user.ini" <<EOF
206 [user::general]
207 account_name=$2
208 account_steamid=$3
209 language=english
210 ip_country=US
211 EOF
212   printf '%s\n' "$2" > "$ss/account_name.txt"
213   printf '%s\n' "$2" > "$ss/force_account_name.txt"   # classic goldberg name override
214   printf '%s\n' "$3" > "$ss/force_steamid.txt"        # classic goldberg id override
215   printf '%s\n' "$3" > "$ss/user_steam_id.txt"
216   # Two same-host instances (each in its own Proton container) may not reach each
217   # other via UDP broadcast, so co-op hangs at "Creating online session". Tell the
218   # emu to send discovery directly to localhost so they always find each other.
219   printf '127.0.0.1\n' > "$ss/custom_broadcasts.txt"
220   # BL2 stalls on "Creating online session" waiting on (unreachable) online Steam
221   # servers; offline mode makes the emu report Steam offline so it skips that wait
222   # and goes to LAN. LAN co-op still works. Remove this file if it ever breaks join.
223   cat > "$ss/configs.main.ini" <<EOF
224 [main::connectivity]
225 offline=1
226 EOF
227 }
228
229 # Old games hard-crash if the emu returns interfaces they don't expect. Extract
230 # the exact interface versions from the game's ORIGINAL Steam lib.
231 extract_interfaces() {  # $1=steam_settings dir  $2=path to original steam_api lib
232   strings -n 5 "$2" 2>/dev/null | grep -E \
233     '^(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}$' \
234     | sort -u > "$1/steam_interfaces.txt"
235 }
236
237 build_player() {  # $1=n  $2=account  $3=steamid
238   local n="$1" acct="$2" sid="$3" pg="$BASE/p$1/game"
239   say "  [$n] symlink game copy -> $pg"
240   rm -rf "$BASE/p$n/game"; mkdir -p "$BASE/p$n/home"
241   cp -as "$GAME_DIR/." "$pg/" || die "cp -as failed (need GNU coreutils)"
242   if [ "$MODE" = native ]; then
243     rm -f "$pg/libsteam_api.so"; cp "$GOLDBERG_SO" "$pg/libsteam_api.so"
244     write_goldberg_settings "$pg" "$acct" "$sid"
245     extract_interfaces "$pg/steam_settings" "$GAME_DIR/libsteam_api.so"
246     [ -s "$pg/steam_settings/steam_interfaces.txt" ] || warn "  [$n] no interfaces extracted (game may crash)"
247   else
248     local w="$pg/Binaries/Win32"
249     # Wine resolves a SYMLINKED exe to its target directory and loads DLLs from
250     # THERE (the real steam_api.dll -> "Steam must be running"), bypassing our
251     # Goldberg dll. Make the exes real copies so DLL search happens in our dir.
252     local e real
253     for e in Borderlands2.exe Launcher.exe; do
254       [ -L "$w/$e" ] && { real=$(readlink -f "$w/$e"); cp --remove-destination "$real" "$w/$e"; }
255     done
256     # keep a copy of the original Windows lib before overwriting, for interfaces
257     [ -f "$w/steam_api.dll.orig" ] || cp -L "$w/steam_api.dll" "$w/steam_api.dll.orig" 2>/dev/null
258     write_goldberg_settings "$w" "$acct" "$sid"
259     extract_interfaces "$w/steam_settings" "$w/steam_api.dll.orig"
260     rm -f "$w/steam_api.dll"; cp "$GOLDBERG_DLL" "$w/steam_api.dll"
261     mkdir -p "$BASE/p$n/prefix"
262   fi
263   ok "  [$n] account '$acct' (steamid $sid)"
264 }
265
266 cmd_setup() {
267   cmd_fetch                                     # download Goldberg if not present
268   REQUIRE_PADS=0 cmd_check || die "check failed - resolve the above before setup"
269   say ""; say "== Building player copies ($MODE) =="
270   build_player 1 "Player1" "76561197960287930"
271   build_player 2 "Player2" "76561197960287931"
272   say ""; ok "Setup complete.  Launch with:  ./bl2-splitscreen.sh run"
273 }
274
275 # -------------------------------------------------------------------- run ---
276 # Pin the game to windowed half-screen in its own config, so it doesn't apply a
277 # saved fullscreen mode after loading (which overrides -windowed and unstacks the
278 # tiling). Section-aware: ResX/Fullscreen appear in several sections.
279 patch_native_config() {  # $1=player home  $2=width  $3=height
280   local f
281   f=$(find "$1" -ipath "*willowgame/config/willowengine.ini" 2>/dev/null | head -1)
282   [ -f "$f" ] || return 0
283   # Config uses CRLF: strip trailing CR for matching, re-add it on output.
284   awk -v W="$2" -v H="$3" '
285     { cr = (sub(/\r$/,"") ? "\r" : "") }
286     /^\[/ { sec=$0 }
287     {
288       l=$0
289       if (sec=="[WinDrv.WindowsClient]") {
290         if (l ~ /^StartupFullscreen=/)  l="StartupFullscreen=False"
291         else if (l ~ /^StartupResolutionX=/) l="StartupResolutionX=" W
292         else if (l ~ /^StartupResolutionY=/) l="StartupResolutionY=" H
293       } else if (sec=="[SystemSettings]") {
294         if (l ~ /^Fullscreen=/)          l="Fullscreen=False"
295         else if (l ~ /^WindowedFullscreen=/) l="WindowedFullscreen=False"
296         else if (l ~ /^ResX=/)           l="ResX=" W
297         else if (l ~ /^ResY=/)           l="ResY=" H
298       } else if (sec=="[FullScreenMovie]") {
299         if (l ~ /^StartupMovies=/) next                 # drop the intro logos
300         else if (l ~ /^bForceNoMovies=/) l="bForceNoMovies=TRUE"
301       }
302       printf "%s%s\n", l, cr
303     }' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
304 }
305
306 # Proton keeps its config inside the Wine prefix. We patch WillowEngine.ini to:
307 #  - drop the intro movies (straight to menu; resolution is set by -ResX/-ResY);
308 #  - always keep RUNNING when unfocused (bPauseOnLossOfFocus=FALSE) - split-screen
309 #    focuses only one window at a time, and the default TRUE freezes the unfocused
310 #    instance (its load stalls until you nudge the mouse);
311 #  - mute when unfocused (bMuteAudioWhenNotInFocus=TRUE) so audio follows the focused
312 #    window instead of every instance playing at once.
313 patch_proton_config() {  # $1 = prefix dir
314   local f
315   f=$(find "$1/drive_c/users" -ipath "*Borderlands 2*Config/WillowEngine.ini" 2>/dev/null | head -1)
316   [ -f "$f" ] || return 0
317   awk '
318     { cr = (sub(/\r$/,"") ? "\r" : "") }
319     /^\[/ { sec=$0 }
320     { l=$0
321       if (sec=="[FullScreenMovie]") { if (l ~ /^StartupMovies=/) next; else if (l ~ /^bForceNoMovies=/) l="bForceNoMovies=TRUE" }
322       else if (sec=="[Engine.Engine]") {
323         if (l ~ /^bPauseOnLossOfFocus=/)     l="bPauseOnLossOfFocus=FALSE"
324         else if (l ~ /^bMuteAudioWhenNotInFocus=/) l="bMuteAudioWhenNotInFocus=TRUE"
325       }
326       printf "%s%s\n", l, cr }' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
327 }
328
329 mask_other_pad_args() {  # bwrap args hiding every pad except kept node $1
330   local keep="$1" node other g m
331   for g in $P1_PAD_GLOB $P2_PAD_GLOB; do for m in $g; do
332     [ -e "$m" ] || continue
333     node=$(readlink -f "$m"); [ "$node" = "$keep" ] && continue
334     printf -- '--bind /dev/null %s ' "$node"
335     other="${m%-event-joystick}-joystick"
336     [ -e "$other" ] && printf -- '--bind /dev/null %s ' "$(readlink -f "$other")"
337   done; done
338 }
339
340 launch_player() {  # $1=n  $2=pad-node  $3=vidpid  -> sets LAUNCHED_PID
341   local n="$1" pad="$2" vidpid="$3" pg="$BASE/p$1/game" wd
342   local rw="${GW[$n]}" rh="${GH[$n]}"     # this player's render size
343   local -a env cmd
344   # Run under gamemode if installed: it flips the CPU to the performance governor
345   # (system-wide) while playing, so instances load at full speed instead of stalling
346   # on a power-saving governor until you jiggle the mouse. No-op if not installed.
347   local -a gm=(); command -v gamemoderun >/dev/null && gm=(gamemoderun)
348   # No gamescope in either mode: it doesn't pass raw controllers to nested clients,
349   # so the game sees no pad. Run the game windowed and let KWin place it.
350   if [ "$MODE" = native ]; then
351     local home="$BASE/p$n/home"; wd="$pg"
352     patch_native_config "$home" "$rw" "$rh"     # pin windowed size in the config
353     env=( "HOME=$home" "XDG_DATA_HOME=$home/.local/share" "XDG_CONFIG_HOME=$home/.config"
354           "LD_LIBRARY_PATH=$pg:${LD_LIBRARY_PATH:-}"
355           "SteamAppId=$APPID" "SteamGameId=$APPID"
356           "SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS=1" )
357     cmd=( "${gm[@]}" "$NATIVE_RUNTIME" "./$NATIVE_BIN" -windowed "-ResX=$rw" "-ResY=$rh" )
358   else
359     wd="$pg/Binaries/Win32"
360     patch_proton_config "$BASE/p$n/prefix"     # movies off, no focus-pause, mute-when-unfocused
361     mkdir -p "$BASE/dxvk-cache"
362     env=( "WINEPREFIX=$BASE/p$n/prefix" "GAMEID=0" "STORE=none"
363           "UMU_RUNTIME_UPDATE=0"                # skip umu's startup update check
364           # Shared D3D shader cache: the 2nd instance reuses shaders the 1st already
365           # compiled instead of recompiling them while the 1st is using the GPU.
366           "DXVK_STATE_CACHE_PATH=$BASE/dxvk-cache" )
367     # Only use the SDL VID/PID filter in "sdl" mode. With bwrap masking it is
368     # redundant AND can over-filter (it wrongly dropped the 360 pad), so skip it.
369     [ "$ISOLATION" = sdl ] && env+=( "SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=$vidpid" )
370     [ -n "$PROTONPATH" ] && env+=( "PROTONPATH=$PROTONPATH" )
371     cmd=( "${gm[@]}" umu-run "$pg/$WIN_EXE_REL" -windowed "-ResX=$rw" "-ResY=$rh" )
372   fi
373
374   # bwrap (if used) masks the other pad's /dev/input nodes for this instance.
375   local -a inner
376   if [ "$ISOLATION" = bwrap ]; then
377     local -a maskargs=(); read -ra maskargs < <(mask_other_pad_args "$pad")
378     inner=( bwrap --dev-bind / / --die-with-parent --chdir "$wd" "${maskargs[@]}" -- "${cmd[@]}" )
379   else
380     inner=( "${cmd[@]}" )
381   fi
382
383   say "${c_dim}[$n] pad=$pad  ${rw}x${rh} @ ${GX[$n]},${GY[$n]}  log=$BASE/p$n/log${c_rst}"
384   # Background in the CURRENT shell (not a $() subshell) so the job is a real
385   # child that cmd_run can wait on; hand the PID back via a global.
386   ( cd "$wd" || exit 1; exec env "${env[@]}" "${inner[@]}" ) >"$BASE/p$n/log" 2>&1 &
387   LAUNCHED_PID=$!
388 }
389
390 # --------------------------------------------------------- KDE/KWin tiling --
391 # One-shot: place each BL2 window on its half in LOGICAL coordinates. Run AFTER
392 # the splash movies so the game has settled at its configured size (tiling during
393 # the splash fights the game and looks wrong). All geometry is logical px.
394 cmd_tile() {
395   command -v qdbus6 >/dev/null || { warn "qdbus6 not found - cannot auto-tile (drag windows manually)"; return 0; }
396   # Left/right split, both windows vertically centered (WIN_Y).
397   # Per-window geometry: window[0]=player1, window[1]=player2 (launch order).
398   local js; js="$(mktemp --suffix=.js)"
399   cat > "$js" <<EOF
400 (function(){
401   var geo=[{x:${GX[1]},y:${GY[1]},w:${GW[1]},h:${GH[1]}},
402            {x:${GX[2]},y:${GY[2]},w:${GW[2]},h:${GH[2]}}];
403   var l=(workspace.windowList?workspace.windowList():workspace.clientList());
404   var wins=l.filter(function(w){
405     var s=((w.resourceClass||"")+" "+(w.resourceName||"")+" "+(w.caption||"")).toLowerCase();
406     return s.indexOf("borderlands")>=0 || s.indexOf("steam_app")>=0;
407   });
408   wins.sort(function(a,b){ return (""+a.internalId)<(""+b.internalId)?-1:1; });
409   for(var i=0;i<wins.length && i<2;i++){
410     var w=wins[i], g=geo[i];
411     try{w.fullScreen=false;}catch(e){}
412     try{w.setMaximize(false,false);}catch(e){}
413     try{w.noBorder=true;}catch(e){}
414     w.frameGeometry={x:g.x,y:g.y,width:g.w,height:g.h};
415   }
416 })();
417 EOF
418   qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript "$js" bl2tile >/dev/null 2>&1
419   qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.start >/dev/null 2>&1
420   qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.unloadScript bl2tile >/dev/null 2>&1
421   rm -f "$js"
422   ok "Placed windows (${DISPLAY_MODE}: ${GW[1]}x${GH[1]} + ${GW[2]}x${GH[2]})."
423 }
424
425 cmd_run() {
426   [ -d "$BASE/p1/game" ] && [ -d "$BASE/p2/game" ] || die "run setup first"
427   local p1 p2; p1=$(resolve_pad "$P1_PAD_GLOB"); p2=$(resolve_pad "$P2_PAD_GLOB")
428   [ -n "$p1" ] || die "Player 1 controller not connected"
429   [ -n "$p2" ] || die "Player 2 controller not connected (turn it on)"
430
431   # Steam Input (active while Steam runs) injects VIRTUAL gamepads that SDL grabs
432   # instead of the real pads, which defeats per-instance controller isolation.
433   # Goldberg replaces Steam, so it isn't needed while playing.
434   if pgrep -x steam >/dev/null; then
435     warn "Steam is running - Steam Input's virtual gamepads can break per-player"
436     warn "controller isolation. If a pad drives the wrong window, QUIT STEAM"
437     warn "(Goldberg replaces it). Continuing in 5s..."
438     sleep 5
439   fi
440
441   say "== Launching Borderlands 2 (mode=$MODE, isolation=$ISOLATION, display=$DISPLAY_MODE) =="
442   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}"
443   local pid1 pid2
444   launch_player 1 "$p1" "$P1_VIDPID"; pid1=$LAUNCHED_PID
445   [ "$STAGGER" -gt 0 ] 2>/dev/null && sleep "$STAGGER"
446   launch_player 2 "$p2" "$P2_VIDPID"; pid2=$LAUNCHED_PID
447
448   local where="THEIR MONITORS"; [ "$DISPLAY_MODE" = split ] && where="THEIR HALVES"
449   banner "WIGGLE YOUR MOUSE NOW -- KEEP IT MOVING" \
450          "UNTIL BOTH GAMES REACH THE MAIN MENU." \
451          "" \
452          "DO NOT TOUCH THE WINDOWS: I WILL MOVE THEM" \
453          "ONTO $where IN $SPLASH_WAIT SECONDS."
454
455   # Move windows AFTER the splash - the game resizes itself during it, so tiling too
456   # early makes the window flicker. Fire a few times so both settle.
457   ( sleep "$SPLASH_WAIT"; cmd_tile >/dev/null 2>&1
458     sleep 8; cmd_tile >/dev/null 2>&1
459     sleep 12; cmd_tile >/dev/null 2>&1 ) &
460
461   ok "Both instances launching (pids $pid1 / $pid2)."
462   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."
463   say "${c_dim}Re-place windows any time with:  ./bl2-splitscreen.sh tile${c_rst}"
464   say "Press Ctrl-C to kill both instances."
465   # Killing $pid1/$pid2 only hits the top of each tree; the game runs deeper inside
466   # umu/pressure-vessel and survives. kill_instances tears down the whole tree.
467   trap 'echo; kill_instances; exit 130' INT TERM
468   wait "$pid1" "$pid2"
469 }
470
471 # ------------------------------------------------------------------ kill ----
472 # Terminate every Borderlands 2 process we launched (game exe + umu/Proton/Wine
473 # tree for our prefixes), SIGTERM first then SIGKILL. Scoped to our BASE dir so it
474 # won't touch unrelated Wine apps.
475 kill_instances() {
476   local sig
477   for sig in TERM KILL; do
478     pkill -"$sig" -f 'Borderlands2\.exe'   2>/dev/null   # proton game
479     pkill -"$sig" -x 'Borderlands2'        2>/dev/null   # native game
480     pkill -"$sig" -f "$BASE/p[12]/"        2>/dev/null   # umu/proton/wine for our copies+prefixes
481     [ "$sig" = TERM ] && sleep 2
482   done
483 }
484
485 cmd_kill() {
486   # Match the process NAME (comm) as a substring, not the full cmdline - otherwise
487   # helper shells that merely have the game path in their args get counted. This
488   # matches "Borderlands2.ex" (Proton, 15-char truncated) and "Borderlands2".
489   pgrep 'Borderlands2' >/dev/null || { ok "No Borderlands 2 instances running."; return 0; }
490   say "Killing Borderlands 2 instances..."
491   kill_instances
492   # pgrep -c prints "0" and exits non-zero on no match, so DON'T add `|| echo 0`
493   # (that would double it). Command substitution keeps the "0" regardless of exit.
494   local left; left=$(pgrep -c 'Borderlands2' 2>/dev/null); left=${left:-0}
495   [ "$left" = 0 ] && ok "All instances killed." || warn "$left process(es) still lingering."
496 }
497
498 # ------------------------------------------------------------------ clean ---
499 cmd_clean() { cmd_kill; say "Removing generated copies/saves (real game untouched)..."; rm -rf "$BASE/p1" "$BASE/p2"; ok "Done."; }
500
501 case "${1:-}" in
502   fetch) cmd_fetch ;;
503   check) cmd_check ;;
504   setup) cmd_setup ;;
505   run)   cmd_run ;;
506   tile)  cmd_tile ;;
507   kill)  cmd_kill ;;
508   clean) cmd_clean ;;
509   *) say "Usage: $0 {fetch|check|setup|run|tile|kill|clean}   (MODE=native|proton to force)"; exit 1 ;;
510 esac