]>
| Commit | Line | Data |
|---|---|---|
| 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 | |
| 110 | GOLDBERG_SO="$BASE/goldberg/libsteam_api.so" # native | |
| 111 | GOLDBERG_DLL="$BASE/goldberg/steam_api.dll" # proton | |
| 112 | ||
| 113 | resolve_pad() { local m; for m in $1; do [ -e "$m" ] && { readlink -f "$m"; return; }; done; } | |
| 114 | ||
| 115 | # ------------------------------------------------------------------ check --- | |
| 116 | cmd_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 --- | |
| 160 | write_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] | |
| 165 | account_name=$2 | |
| 166 | account_steamid=$3 | |
| 167 | language=english | |
| 168 | ip_country=US | |
| 169 | EOF | |
| 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. | |
| 182 | extract_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 | ||
| 188 | build_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 | ||
| 217 | cmd_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. | |
| 229 | patch_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). | |
| 258 | patch_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 | ||
| 270 | mask_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 | ||
| 281 | launch_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. | |
| 326 | cmd_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 | })(); | |
| 349 | EOF | |
| 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 | ||
| 357 | cmd_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 --- | |
| 398 | cmd_clean() { say "Removing generated copies/saves (real game untouched)..."; rm -rf "$BASE/p1" "$BASE/p2"; ok "Done."; } | |
| 399 | ||
| 400 | case "${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 ;; | |
| 407 | esac |