]> vilimpoc.org git repositories - bl2-split/blame - bl2-splitscreen.sh
Initial commit
[bl2-split] / bl2-splitscreen.sh
CommitLineData
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# ---------------------------------------------------------------------------
22set -uo pipefail
23
24# ============================ CONFIG =======================================
25GAME_DIR="/home/max/.local/share/Steam/steamapps/common/Borderlands 2"
26APPID=49520
27BASE="/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
34while 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))
39done <<< "$_mons"
40MON_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.
48if [ "$MON_COUNT" -ge 2 ]; then DISPLAY_MODE="${DISPLAY_MODE:-dual}"; else DISPLAY_MODE="${DISPLAY_MODE:-split}"; fi
49
50if [ "$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]}
53else
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
59fi
60
61# Controllers, matched by STABLE /dev/input/by-id names (survive replug/reboot).
62P1_PAD_GLOB="/dev/input/by-id/*Microsoft_Controller_*-event-joystick" # Xbox Series S|X (wired)
63P2_PAD_GLOB="/dev/input/by-id/*Wireless_Receiver*-event-joystick" # Xbox 360 Wireless Receiver
64P1_VIDPID="0x045e/0x0b12" # Xbox Series S|X (SDL hint; used in proton mode)
65P2_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)
71ISOLATION="${ISOLATION:-}"
72
73# Proton build for umu (proton mode). Empty = umu auto-selects/downloads UMU-Proton.
74PROTONPATH="${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.
79NATIVE_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`.
84SPLASH_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.
89STAGGER="${STAGGER:-25}"
90# ===========================================================================
91
92c_red=$'\e[31m'; c_grn=$'\e[32m'; c_yel=$'\e[33m'; c_dim=$'\e[2m'; c_rst=$'\e[0m'
93say() { printf '%s\n' "$*"; }
94ok() { printf '%s✓%s %s\n' "$c_grn" "$c_rst" "$*"; }
95warn() { printf '%s!%s %s\n' "$c_yel" "$c_rst" "$*"; }
96die() { printf '%s✗ %s%s\n' "$c_red" "$*" "$c_rst" >&2; exit 1; }
97
98# -------------------------------------------------------- mode detection ---
99WIN_EXE_REL="Binaries/Win32/Borderlands2.exe"
100NATIVE_BIN="Borderlands2"
101detect_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}
107MODE="$(detect_mode)"
108[ "$MODE" = unknown ] && die "Cannot find BL2 - neither $NATIVE_BIN nor $WIN_EXE_REL under $GAME_DIR"
109if [ -z "$ISOLATION" ]; then ISOLATION=bwrap; fi # device-masking works for both modes
110GOLDBERG_SO="$BASE/goldberg/libsteam_api.so" # native
111GOLDBERG_DLL="$BASE/goldberg/steam_api.dll" # proton
112
113resolve_pad() { local m; for m in $1; do [ -e "$m" ] && { readlink -f "$m"; return; }; done; }
114
115# ------------------------------------------------------------------ check ---
116cmd_check() {
117 local fail=0
118 say "== Mode: ${c_grn}$MODE${c_rst} (isolation=$ISOLATION) =="
119 say ""; say "== Prerequisites =="
120 command -v qdbus6 >/dev/null && ok "qdbus6 (KWin tiling)" || warn "qdbus6 missing - auto-tiling disabled ${c_dim}(sudo pacman -S qt6-tools)${c_rst}"
121 [ "$ISOLATION" = bwrap ] && { command -v bwrap >/dev/null && ok "bwrap" || { warn "bwrap missing"; fail=1; }; }
122 if [ "$MODE" = proton ]; then
123 command -v gamescope >/dev/null && ok "gamescope" || { warn "gamescope missing ${c_dim}(sudo pacman -S gamescope)${c_rst}"; fail=1; }
124 command -v umu-run >/dev/null && ok "umu-run" || { warn "umu-run missing ${c_dim}(sudo pacman -S umu-launcher)${c_rst}"; fail=1; }
125 else
126 [ -f "$NATIVE_RUNTIME" ] && ok "Steam scout runtime" || { warn "scout runtime missing: $NATIVE_RUNTIME"; fail=1; }
127 command -v strings >/dev/null && ok "strings (interface extraction)" || { warn "strings missing ${c_dim}(sudo pacman -S binutils)${c_rst}"; fail=1; }
128 fi
129
130 say ""; say "== Game =="
131 if [ "$MODE" = native ]; then
132 [ -x "$GAME_DIR/$NATIVE_BIN" ] && file "$GAME_DIR/$NATIVE_BIN" | grep -q ELF \
133 && ok "native ELF: $NATIVE_BIN" || { warn "native binary missing"; fail=1; }
134 else
135 [ -f "$GAME_DIR/$WIN_EXE_REL" ] && ok "Borderlands2.exe" || { warn "exe missing: $WIN_EXE_REL"; fail=1; }
136 fi
137
138 say ""; say "== Goldberg =="
139 if [ "$MODE" = native ]; then
140 if [ -f "$GOLDBERG_SO" ] && file "$GOLDBERG_SO" | grep -q "ELF 32-bit"; then ok "libsteam_api.so (classic Mr_Goldberg, 32-bit ELF)"
141 else warn "missing/invalid $GOLDBERG_SO (need classic Mr_Goldberg linux/x86 libsteam_api.so)"; fail=1; fi
142 else
143 if [ -f "$GOLDBERG_DLL" ] && file "$GOLDBERG_DLL" | grep -qi "PE32 .*i386"; then ok "steam_api.dll (PE32 i386)"
144 else warn "missing/invalid $GOLDBERG_DLL (need Windows x86 gbe_fork experimental)"; fail=1; fi
145 fi
146
147 say ""; say "== Controllers =="
148 local p1 p2 padfail=0; p1=$(resolve_pad "$P1_PAD_GLOB"); p2=$(resolve_pad "$P2_PAD_GLOB")
149 [ -n "$p1" ] && ok "Player 1 pad -> $p1" || { warn "Player 1 pad not connected"; padfail=1; }
150 [ -n "$p2" ] && ok "Player 2 pad -> $p2" || { warn "Player 2 pad not connected (turn it on)"; padfail=1; }
151 # Controllers only matter for 'run'; setup passes REQUIRE_PADS=0.
152 [ "${REQUIRE_PADS:-1}" = 1 ] && [ "$padfail" = 1 ] && fail=1
153
154 say ""
155 [ "$fail" = 0 ] && ok "All good - run: ./bl2-splitscreen.sh setup" || warn "Fix the above, then re-run check."
156 return $fail
157}
158
159# ------------------------------------------------------------------ setup ---
160write_goldberg_settings() { # $1=dir $2=account $3=steamid
161 local ss="$1/steam_settings"; mkdir -p "$ss"
162 printf '%s\n' "$APPID" > "$1/steam_appid.txt"
163 cat > "$ss/configs.user.ini" <<EOF
164[user::general]
165account_name=$2
166account_steamid=$3
167language=english
168ip_country=US
169EOF
170 printf '%s\n' "$2" > "$ss/account_name.txt"
171 printf '%s\n' "$2" > "$ss/force_account_name.txt" # classic goldberg name override
172 printf '%s\n' "$3" > "$ss/force_steamid.txt" # classic goldberg id override
173 printf '%s\n' "$3" > "$ss/user_steam_id.txt"
174 # Two same-host instances (each in its own Proton container) may not reach each
175 # other via UDP broadcast, so co-op hangs at "Creating online session". Tell the
176 # emu to send discovery directly to localhost so they always find each other.
177 printf '127.0.0.1\n' > "$ss/custom_broadcasts.txt"
178}
179
180# Old games hard-crash if the emu returns interfaces they don't expect. Extract
181# the exact interface versions from the game's ORIGINAL Steam lib.
182extract_interfaces() { # $1=steam_settings dir $2=path to original steam_api lib
183 strings -n 5 "$2" 2>/dev/null | grep -E \
184 '^(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}$' \
185 | sort -u > "$1/steam_interfaces.txt"
186}
187
188build_player() { # $1=n $2=account $3=steamid
189 local n="$1" acct="$2" sid="$3" pg="$BASE/p$1/game"
190 say " [$n] symlink game copy -> $pg"
191 rm -rf "$BASE/p$n/game"; mkdir -p "$BASE/p$n/home"
192 cp -as "$GAME_DIR/." "$pg/" || die "cp -as failed (need GNU coreutils)"
193 if [ "$MODE" = native ]; then
194 rm -f "$pg/libsteam_api.so"; cp "$GOLDBERG_SO" "$pg/libsteam_api.so"
195 write_goldberg_settings "$pg" "$acct" "$sid"
196 extract_interfaces "$pg/steam_settings" "$GAME_DIR/libsteam_api.so"
197 [ -s "$pg/steam_settings/steam_interfaces.txt" ] || warn " [$n] no interfaces extracted (game may crash)"
198 else
199 local w="$pg/Binaries/Win32"
200 # Wine resolves a SYMLINKED exe to its target directory and loads DLLs from
201 # THERE (the real steam_api.dll -> "Steam must be running"), bypassing our
202 # Goldberg dll. Make the exes real copies so DLL search happens in our dir.
203 local e real
204 for e in Borderlands2.exe Launcher.exe; do
205 [ -L "$w/$e" ] && { real=$(readlink -f "$w/$e"); cp --remove-destination "$real" "$w/$e"; }
206 done
207 # keep a copy of the original Windows lib before overwriting, for interfaces
208 [ -f "$w/steam_api.dll.orig" ] || cp -L "$w/steam_api.dll" "$w/steam_api.dll.orig" 2>/dev/null
209 write_goldberg_settings "$w" "$acct" "$sid"
210 extract_interfaces "$w/steam_settings" "$w/steam_api.dll.orig"
211 rm -f "$w/steam_api.dll"; cp "$GOLDBERG_DLL" "$w/steam_api.dll"
212 mkdir -p "$BASE/p$n/prefix"
213 fi
214 ok " [$n] account '$acct' (steamid $sid)"
215}
216
217cmd_setup() {
218 REQUIRE_PADS=0 cmd_check || die "check failed - resolve the above before setup"
219 say ""; say "== Building player copies ($MODE) =="
220 build_player 1 "Player1" "76561197960287930"
221 build_player 2 "Player2" "76561197960287931"
222 say ""; ok "Setup complete. Launch with: ./bl2-splitscreen.sh run"
223}
224
225# -------------------------------------------------------------------- run ---
226# Pin the game to windowed half-screen in its own config, so it doesn't apply a
227# saved fullscreen mode after loading (which overrides -windowed and unstacks the
228# tiling). Section-aware: ResX/Fullscreen appear in several sections.
229patch_native_config() { # $1=player home $2=width $3=height
230 local f
231 f=$(find "$1" -ipath "*willowgame/config/willowengine.ini" 2>/dev/null | head -1)
232 [ -f "$f" ] || return 0
233 # Config uses CRLF: strip trailing CR for matching, re-add it on output.
234 awk -v W="$2" -v H="$3" '
235 { cr = (sub(/\r$/,"") ? "\r" : "") }
236 /^\[/ { sec=$0 }
237 {
238 l=$0
239 if (sec=="[WinDrv.WindowsClient]") {
240 if (l ~ /^StartupFullscreen=/) l="StartupFullscreen=False"
241 else if (l ~ /^StartupResolutionX=/) l="StartupResolutionX=" W
242 else if (l ~ /^StartupResolutionY=/) l="StartupResolutionY=" H
243 } else if (sec=="[SystemSettings]") {
244 if (l ~ /^Fullscreen=/) l="Fullscreen=False"
245 else if (l ~ /^WindowedFullscreen=/) l="WindowedFullscreen=False"
246 else if (l ~ /^ResX=/) l="ResX=" W
247 else if (l ~ /^ResY=/) l="ResY=" H
248 } else if (sec=="[FullScreenMovie]") {
249 if (l ~ /^StartupMovies=/) next # drop the intro logos
250 else if (l ~ /^bForceNoMovies=/) l="bForceNoMovies=TRUE"
251 }
252 printf "%s%s\n", l, cr
253 }' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
254}
255
256# Proton keeps its config inside the Wine prefix; drop the intro movies there so
257# the game goes straight to the menu (resolution is handled by -ResX/-ResY args).
258patch_proton_config() { # $1 = prefix dir
259 local f
260 f=$(find "$1/drive_c/users" -ipath "*Borderlands 2*Config/WillowEngine.ini" 2>/dev/null | head -1)
261 [ -f "$f" ] || return 0
262 awk '
263 { cr = (sub(/\r$/,"") ? "\r" : "") }
264 /^\[/ { sec=$0 }
265 { l=$0
266 if (sec=="[FullScreenMovie]") { if (l ~ /^StartupMovies=/) next; else if (l ~ /^bForceNoMovies=/) l="bForceNoMovies=TRUE" }
267 printf "%s%s\n", l, cr }' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
268}
269
270mask_other_pad_args() { # bwrap args hiding every pad except kept node $1
271 local keep="$1" node other g m
272 for g in $P1_PAD_GLOB $P2_PAD_GLOB; do for m in $g; do
273 [ -e "$m" ] || continue
274 node=$(readlink -f "$m"); [ "$node" = "$keep" ] && continue
275 printf -- '--bind /dev/null %s ' "$node"
276 other="${m%-event-joystick}-joystick"
277 [ -e "$other" ] && printf -- '--bind /dev/null %s ' "$(readlink -f "$other")"
278 done; done
279}
280
281launch_player() { # $1=n $2=pad-node $3=vidpid -> sets LAUNCHED_PID
282 local n="$1" pad="$2" vidpid="$3" pg="$BASE/p$1/game" wd
283 local rw="${GW[$n]}" rh="${GH[$n]}" # this player's render size
284 local -a env cmd
285 # No gamescope in either mode: it doesn't pass raw controllers to nested clients,
286 # so the game sees no pad. Run the game windowed and let KWin place it.
287 if [ "$MODE" = native ]; then
288 local home="$BASE/p$n/home"; wd="$pg"
289 patch_native_config "$home" "$rw" "$rh" # pin windowed size in the config
290 env=( "HOME=$home" "XDG_DATA_HOME=$home/.local/share" "XDG_CONFIG_HOME=$home/.config"
291 "LD_LIBRARY_PATH=$pg:${LD_LIBRARY_PATH:-}"
292 "SteamAppId=$APPID" "SteamGameId=$APPID"
293 "SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS=1" )
294 cmd=( "$NATIVE_RUNTIME" "./$NATIVE_BIN" -windowed "-ResX=$rw" "-ResY=$rh" )
295 else
296 wd="$pg/Binaries/Win32"
297 patch_proton_config "$BASE/p$n/prefix" # skip intro movies (if prefix exists)
298 env=( "WINEPREFIX=$BASE/p$n/prefix" "GAMEID=0" "STORE=none" )
299 # Only use the SDL VID/PID filter in "sdl" mode. With bwrap masking it is
300 # redundant AND can over-filter (it wrongly dropped the 360 pad), so skip it.
301 [ "$ISOLATION" = sdl ] && env+=( "SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=$vidpid" )
302 [ -n "$PROTONPATH" ] && env+=( "PROTONPATH=$PROTONPATH" )
303 cmd=( umu-run "$pg/$WIN_EXE_REL" -windowed "-ResX=$rw" "-ResY=$rh" )
304 fi
305
306 # bwrap (if used) masks the other pad's /dev/input nodes for this instance.
307 local -a inner
308 if [ "$ISOLATION" = bwrap ]; then
309 local -a maskargs=(); read -ra maskargs < <(mask_other_pad_args "$pad")
310 inner=( bwrap --dev-bind / / --die-with-parent --chdir "$wd" "${maskargs[@]}" -- "${cmd[@]}" )
311 else
312 inner=( "${cmd[@]}" )
313 fi
314
315 say "${c_dim}[$n] pad=$pad ${rw}x${rh} @ ${GX[$n]},${GY[$n]} log=$BASE/p$n/log${c_rst}"
316 # Background in the CURRENT shell (not a $() subshell) so the job is a real
317 # child that cmd_run can wait on; hand the PID back via a global.
318 ( cd "$wd" || exit 1; exec env "${env[@]}" "${inner[@]}" ) >"$BASE/p$n/log" 2>&1 &
319 LAUNCHED_PID=$!
320}
321
322# --------------------------------------------------------- KDE/KWin tiling --
323# One-shot: place each BL2 window on its half in LOGICAL coordinates. Run AFTER
324# the splash movies so the game has settled at its configured size (tiling during
325# the splash fights the game and looks wrong). All geometry is logical px.
326cmd_tile() {
327 command -v qdbus6 >/dev/null || { warn "qdbus6 not found - cannot auto-tile (drag windows manually)"; return 0; }
328 # Left/right split, both windows vertically centered (WIN_Y).
329 # Per-window geometry: window[0]=player1, window[1]=player2 (launch order).
330 local js; js="$(mktemp --suffix=.js)"
331 cat > "$js" <<EOF
332(function(){
333 var geo=[{x:${GX[1]},y:${GY[1]},w:${GW[1]},h:${GH[1]}},
334 {x:${GX[2]},y:${GY[2]},w:${GW[2]},h:${GH[2]}}];
335 var l=(workspace.windowList?workspace.windowList():workspace.clientList());
336 var wins=l.filter(function(w){
337 var s=((w.resourceClass||"")+" "+(w.resourceName||"")+" "+(w.caption||"")).toLowerCase();
338 return s.indexOf("borderlands")>=0 || s.indexOf("steam_app")>=0;
339 });
340 wins.sort(function(a,b){ return (""+a.internalId)<(""+b.internalId)?-1:1; });
341 for(var i=0;i<wins.length && i<2;i++){
342 var w=wins[i], g=geo[i];
343 try{w.fullScreen=false;}catch(e){}
344 try{w.setMaximize(false,false);}catch(e){}
345 try{w.noBorder=true;}catch(e){}
346 w.frameGeometry={x:g.x,y:g.y,width:g.w,height:g.h};
347 }
348})();
349EOF
350 qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript "$js" bl2tile >/dev/null 2>&1
351 qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.start >/dev/null 2>&1
352 qdbus6 org.kde.KWin /Scripting org.kde.kwin.Scripting.unloadScript bl2tile >/dev/null 2>&1
353 rm -f "$js"
354 ok "Placed windows (${DISPLAY_MODE}: ${GW[1]}x${GH[1]} + ${GW[2]}x${GH[2]})."
355}
356
357cmd_run() {
358 [ -d "$BASE/p1/game" ] && [ -d "$BASE/p2/game" ] || die "run setup first"
359 local p1 p2; p1=$(resolve_pad "$P1_PAD_GLOB"); p2=$(resolve_pad "$P2_PAD_GLOB")
360 [ -n "$p1" ] || die "Player 1 controller not connected"
361 [ -n "$p2" ] || die "Player 2 controller not connected (turn it on)"
362
363 # Steam Input (active while Steam runs) injects VIRTUAL gamepads that SDL grabs
364 # instead of the real pads, which defeats per-instance controller isolation.
365 # Goldberg replaces Steam, so it isn't needed while playing.
366 if pgrep -x steam >/dev/null; then
367 warn "Steam is running - Steam Input's virtual gamepads can break per-player"
368 warn "controller isolation. If a pad drives the wrong window, QUIT STEAM"
369 warn "(Goldberg replaces it). Continuing in 5s..."
370 sleep 5
371 fi
372
373 say "== Launching Borderlands 2 (mode=$MODE, isolation=$ISOLATION, display=$DISPLAY_MODE) =="
374 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}"
375 local pid1 pid2
376 launch_player 1 "$p1" "$P1_VIDPID"; pid1=$LAUNCHED_PID
377 sleep "$STAGGER"
378 launch_player 2 "$p2" "$P2_VIDPID"; pid2=$LAUNCHED_PID
379
380 # Place windows AFTER the splash (the game resizes itself during it). Fire a few
381 # times so both instances get caught once settled.
382 ( sleep "$SPLASH_WAIT"; cmd_tile >/dev/null 2>&1
383 sleep 10; cmd_tile >/dev/null 2>&1
384 sleep 15; cmd_tile >/dev/null 2>&1 ) &
385
386 say ""
387 ok "Both instances launching (pids $pid1 / $pid2)."
388 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."
389 local where="their monitors"; [ "$DISPLAY_MODE" = split ] && where="their halves"
390 say "${c_dim}Windows settle onto $where ~${SPLASH_WAIT}s in (after the splash)."
391 say "Re-place any time with: ./bl2-splitscreen.sh tile${c_rst}"
392 say ""; say "Press Ctrl-C to kill both instances."
393 trap 'kill "$pid1" "$pid2" 2>/dev/null' INT TERM
394 wait "$pid1" "$pid2"
395}
396
397# ------------------------------------------------------------------ clean ---
398cmd_clean() { say "Removing generated copies/saves (real game untouched)..."; rm -rf "$BASE/p1" "$BASE/p2"; ok "Done."; }
399
400case "${1:-}" in
401 check) cmd_check ;;
402 setup) cmd_setup ;;
403 run) cmd_run ;;
404 tile) cmd_tile ;;
405 clean) cmd_clean ;;
406 *) say "Usage: $0 {check|setup|run|tile|clean} (MODE=native|proton to force)"; exit 1 ;;
407esac