#!/bin/bash
# The Linux runtime's session. Bannerlator execs this under proot with the output size and mode;
# the first pass starts gamescope, which execs this script again inside it for the program:
#   bannerlator-session desktop            the file manager
#   bannerlator-session run <path>         a Linux program (AppImage, script or ELF)
#   bannerlator-session steam [steam-url]  the native Steam client
#   bannerlator-session lxqt               the LXQt/labwc desktop (DroidDeck's desktop package)
# BL_WIDTH / BL_HEIGHT size the output, BL_FPS limits it, BL_LOG collects everything said here.
set -u
export HOME=${HOME:-/root}
mode=${1:-desktop}
shift || true
# One line per milestone, greppable as "== STEP", so a first run reads as a narrative rather than
# something to be reconstructed from Steam's own chatter. The app shows these while it waits.
step() { echo "== STEP $(date -u +%H:%M:%S) $*"; }
if [ -n "${BL_LOG:-}" ] && [ -z "${BL_INSIDE:-}" ]; then
  mkdir -p "$(dirname "$BL_LOG")" 2>/dev/null
  # The client alone writes megabytes an hour; one earlier log is kept, not every session's.
  if [ "$(stat -c %s "$BL_LOG" 2>/dev/null || echo 0)" -gt 8388608 ]; then
    mv -f "$BL_LOG" "$BL_LOG.1"
  fi
  exec >>"$BL_LOG" 2>&1
  echo "== bannerlator-session $(date) mode=$mode $*"
  echo "== uid=$(id -u) size=${BL_WIDTH:-?}x${BL_HEIGHT:-?} fps=${BL_FPS:-0} icd=${VK_ICD_FILENAMES:-none}"
  echo "== gamescope: $(gamescope --version 2>&1 | head -1) at $(command -v gamescope)"
  echo "== rootfs: $(cat /.version 2>/dev/null || echo unversioned)"
fi

# Until the runtime is stable, Steam's own logs are copied beside the session log at exit so a
# failed launch can be handed over as one folder.
collect_steam_logs() {
  [ -n "${BL_DEBUG_DIR:-}" ] || return 0
  local steam_root=${XDG_DATA_HOME:-$HOME/.local/share}/Steam
  mkdir -p "$BL_DEBUG_DIR/steam" 2>/dev/null || return 0
  # Into steam/ beside this session's own logs. The app scrubs these line by line after the
  # session ends - they carry the account's session token and id - so nothing here is the last
  # word on what the user ends up sharing; this is just the copy that survives the runtime.
  [ -d "$steam_root/logs" ] && cp -r "$steam_root/logs/." "$BL_DEBUG_DIR/steam/" 2>/dev/null
  [ -f "$HOME/.steam/steam/logs/steamwebhelper.log" ] && cp "$HOME/.steam/steam/logs/steamwebhelper.log" "$BL_DEBUG_DIR/steam/" 2>/dev/null
  # NOT copied, ever: loginusers.vdf, config.vdf and the ssfn* files, which hold the credentials
  # themselves rather than a mention of them.
  echo "== steam logs copied to $BL_DEBUG_DIR/steam"
}
# A session that is killed never reaches the copy below, and those are the sessions worth reading.
trap 'collect_steam_logs 2>/dev/null || true' TERM INT HUP
# The driver everything in this session draws with: gamescope's compositing, the client's UI
# (OpenGL through Zink), every game Proton launches, and in desktop mode every program on it.
# BL_VK_DRIVER names an imported driver's ICD manifest on the Android side, which is bound into the
# session at its own path; unset means the runtime keeps the Turnip it was built with.
# VK_DRIVER_FILES REPLACES the loader's search, so a manifest whose library is unreadable would
# leave the session with no Vulkan at all - check both before setting it, and say so in the log
# rather than fail silently half a minute later.
if [ -n "${BL_VK_DRIVER:-}" ]; then
  bl_icd_lib=$(sed -n 's/.*"library_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$BL_VK_DRIVER" 2>/dev/null | head -1)
  # JSON may escape the separator: org.json writes "\/data\/user\/0\/...". The Vulkan loader
  # parses that correctly; this check has to as well, or an absolute path looks relative and gets
  # glued onto the manifest's directory - which is exactly what happened the first time.
  bl_icd_lib=$(printf '%s' "$bl_icd_lib" | sed 's|\\/|/|g')
  case $bl_icd_lib in
    /*) ;;
    ?*) bl_icd_lib="$(dirname "$BL_VK_DRIVER")/$bl_icd_lib" ;;
  esac
  if [ -r "$BL_VK_DRIVER" ] && [ -n "$bl_icd_lib" ] && [ -r "$bl_icd_lib" ]; then
    export VK_DRIVER_FILES="$BL_VK_DRIVER"
    # Loaders older than 1.3.207 only know the old name; both are harmless together.
    export VK_ICD_FILENAMES="$BL_VK_DRIVER"
    echo "== vulkan driver: imported $bl_icd_lib (via $BL_VK_DRIVER)"
  else
    echo "== vulkan driver: IGNORING $BL_VK_DRIVER (manifest or ${bl_icd_lib:-library} unreadable); using the runtime's own"
  fi
  unset bl_icd_lib
fi
# The app's gamescope build (tools/gamescope) takes realtime-priority Vulkan queues on this
# switch instead of on CAP_SYS_NICE, which proot never has; it falls back to normal priority
# where the driver refuses, and the runtime's own build ignores it.
export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-1}
export XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share}
export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
export XDG_CACHE_HOME=${XDG_CACHE_HOME:-$HOME/.cache}
mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME"

# The cores a program runs on outside Steam (the desktop and everything started from it, a
# program from the rail): the user's game cores when set (BL_GAME_CPUS), else every core but the
# slowest cluster - Android is free to park an emulator's threads on the little cores, and those
# are what compile shaders and recompile PPU/SPU code. BL_PIN_CORES=0 leaves the scheduler alone.
program_cores() {
  [ "${BL_PIN_CORES:-1}" = 0 ] && return 0
  command -v taskset >/dev/null 2>&1 || return 0
  if [ -n "${BL_GAME_CPUS:-}" ]; then echo "$BL_GAME_CPUS"; return 0; fi
  local f n max min= list=
  for f in /sys/devices/system/cpu/cpu[0-9]*/cpufreq/cpuinfo_max_freq; do
    max=$(cat "$f" 2>/dev/null) || continue
    [ -z "$min" ] || [ "$max" -lt "$min" ] && min=$max
  done
  [ -n "$min" ] || return 0
  for f in /sys/devices/system/cpu/cpu[0-9]*/cpufreq/cpuinfo_max_freq; do
    max=$(cat "$f" 2>/dev/null) || continue
    n=${f#/sys/devices/system/cpu/cpu}; n=${n%%/*}
    [ "$max" -gt "$min" ] && list="$list${list:+,}$n"
  done
  echo "$list"
}

if [ -z "${BL_INSIDE:-}" ]; then
  # The compositor may still be starting, and a socket left by an earlier session is not
  # a compositor: probe until a connection succeeds.
  for _ in $(seq 1 100); do
    wayland-info >/dev/null 2>&1 && break
    sleep 0.1
  done
  # The desktop (DroidDeck's hosted package) is its own nested compositor, labwc, drawing straight
  # into the app's: no gamescope in between.
  if [ "$mode" = lxqt ]; then
    step "starting the desktop"
    [ -x /usr/local/bin/droiddeck-desktop ] || { echo "== the desktop package is not installed"; exit 66; }
    cores=$(program_cores)
    if [ -n "$cores" ]; then
      echo "== desktop cores: $cores (the desktop and every program started from it)"
      exec taskset -c "$cores" /usr/local/bin/droiddeck-desktop
    fi
    exec /usr/local/bin/droiddeck-desktop
  fi
  width=${BL_WIDTH:-1280}
  height=${BL_HEIGHT:-720}
  args=(--backend wayland --expose-wayland -f -W "$width" -H "$height" -w "$width" -h "$height")
  # -r is what gamescope advertises as the refresh rate, and what a game reads as the display's.
  # Left unset it defaults to 60, so a 120 Hz panel offers only 60 Hz in game settings. The FPS
  # limiter wins when it is on (it is a deliberate cap); otherwise use the panel's own rate.
  if [ "${BL_FPS:-0}" -gt 0 ]; then
    args+=(-r "$BL_FPS")
  elif [ "${BL_REFRESH:-0}" -gt 0 ]; then
    args+=(-r "$BL_REFRESH")
  fi
  [ "$mode" = steam ] && args+=(-e)
  # A game that resizes its own window when it loses focus (the Steam menu opening over it) comes
  # back smaller, and gamescope draws a window that is not flagged fullscreen at its own size in
  # the top-left corner. Forcing every window to the output's size keeps it filling the screen.
  # BL_GAMESCOPE_FORCE_FULLSCREEN=0 turns it off.
  [ "$mode" = steam ] && [ "${BL_GAMESCOPE_FORCE_FULLSCREEN:-1}" = 1 ] && args+=(--force-windows-fullscreen)
  # HDR10: the app opened its compositor's colour-management gate for this session (BL_HDR=1);
  # gamescope then offers HDR to its clients through the same wp_color_manager_v1 it binds on ours.
  [ "${BL_HDR:-0}" = 1 ] && args+=(--hdr-enabled)
  # Deck mode's performance overlay (Quick Access Menu → Performance → Overlay Level) is drawn by
  # mangoapp, which gamescope starts beside the client; the client sets the level by rewriting
  # MANGOHUD_CONFIGFILE. The apk stages Valve's aarch64 build (tools/mangoapp) - without it the menu
  # still offers the levels and nothing appears. Starts hidden until the client writes a level.
  # BL_MANGOAPP=0 turns it off (one community build found Valve's mangoapp crashing on Turnip).
  if [ "$mode" = steam ] && [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_MANGOAPP:-1}" = 1 ] \
     && [ "${BL_STEAM_UI:-}" != desktop ] && command -v mangoapp >/dev/null 2>&1; then
    MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX)
    echo no_display > "$MANGOHUD_CONFIGFILE"
    export MANGOHUD_CONFIGFILE STEAM_USE_MANGOAPP=1 STEAM_MANGOAPP_PRESETS_SUPPORTED=1 STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1
    args+=(--mangoapp)
    echo "== gamescope: mangoapp on ($(command -v mangoapp), config $MANGOHUD_CONFIGFILE)"
  fi
  export BL_INSIDE=1
  exec gamescope "${args[@]}" -- "$0" "$mode" "$@"
fi

# gamescope manages X windows: toolkits go through Xwayland, not gamescope's own Wayland socket.
export GDK_BACKEND=x11 QT_QPA_PLATFORM=xcb SDL_VIDEODRIVER=x11
# gamescope's reaper waits for every process left under it before the compositor comes down. A
# process a game left behind - a Wine service blocked on a server that is already gone - is
# reparented there when the client exits, and the screen stays black on it for good. Nothing of
# the session outlives the program it was started for.
reap_orphans() {
  local round p stat pid left
  for round in 1 2 3 4 5; do
    left=
    for p in /proc/[0-9]*; do
      pid=${p#/proc/}
      [ "$pid" = "$$" ] && continue
      stat=$(cat "$p/stat" 2>/dev/null) || continue
      stat=${stat##*) }
      set -- $stat
      [ "${2:-}" = "$PPID" ] && left="$left $pid"
    done
    [ -n "$left" ] || return 0
    echo "== leaving behind:$left"
    kill -9 $left 2>/dev/null
    sleep 0.2
  done
}

case $mode in
  desktop)
    exec pcmanfm
    ;;
  run)
    target=${1:?program path}
    shift
    cores=$(program_cores)
    if [ -n "$cores" ]; then
      echo "== program cores: $cores"
      taskset -cp "$cores" $$ >/dev/null 2>&1 || true
    fi
    # Player 1 on the controller in an emulator that has no profile of its own yet.
    python3 /usr/local/bin/bannerlator-pad-defaults 2>&1 || true
    case ${target,,} in
      *.appimage)
        export APPIMAGE_EXTRACT_AND_RUN=1
        chmod +x "$target" 2>/dev/null
        exec "$target" "$@"
        ;;
      *.sh)
        exec bash "$target" "$@"
        ;;
      *)
        chmod +x "$target" 2>/dev/null
        cd "$(dirname "$target")" && exec "$target" "$@"
        ;;
    esac
    ;;
  steam)
    step "checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)"
    bannerlator-steam-install || exit $?
    step "Steam client ready"
    steam_root=$XDG_DATA_HOME/Steam
    # Register the card library (bound at /mnt/bannerlator-sd) with the client - internal storage is
    # its main library already - and the ARM64 Proton that lets it launch the Windows ones.
    # A request left in ~/.bl-proton-extra installs a third-party Proton before the client starts,
    # so it is in the Compatibility dropdown the first time the user looks. One per line, each the
    # arguments to bannerlator-proton-extra: "ge", "cachyos", "ge --tag GE-Proton11-7", or a URL.
    # Hundreds of megabytes each, so it is only ever done on request, and the line is dropped once
    # it has succeeded - a failed download stays and is retried next session.
    if [ -s "$HOME/.bl-proton-extra" ]; then
      _bl_left=$HOME/.bl-proton-extra.left
      : > "$_bl_left"
      while read -r _bl_extra || [ -n "$_bl_extra" ]; do
        case ${_bl_extra# } in ""|"#"*) continue ;; esac
        step "installing Proton '$_bl_extra' (large download, several minutes)"
        if bannerlator-proton-extra "$steam_root" $_bl_extra; then
          step "installed Proton '$_bl_extra'"
        else
          step "Proton '$_bl_extra' failed; will retry next session"
          echo "$_bl_extra" >> "$_bl_left"
        fi
      done < "$HOME/.bl-proton-extra"
      mv -f "$_bl_left" "$HOME/.bl-proton-extra"
    fi
    # steam:// URLs left in ~/.bl-steam-urls are handed to the client on its command line. The app
    # writes one when the user asks for a Proton that is one of Valve's own depots rather than a
    # tarball: those are not files anywhere, the client fetches them like any other app. One per
    # line, and the file is cleared once they have been passed on - the client either acts on a URL
    # or it does not, and repeating it every session would re-ask forever.
    if [ -s "$HOME/.bl-steam-urls" ]; then
      while read -r _bl_url || [ -n "$_bl_url" ]; do
        case ${_bl_url# } in steam://*) ;; *) continue ;; esac
        step "asking the client for $_bl_url"
        set -- "$@" "$_bl_url"
      done < "$HOME/.bl-steam-urls"
      rm -f "$HOME/.bl-steam-urls"
    fi
    # Cores for the client itself. Steam pins its own interface renderer to a subset - on the
    # device it took 5 of 8, leaving out both little cores and the fastest one - which is a
    # reasonable thing to do while a game is running and the wrong thing when the client is all
    # there is. BL_CLIENT_CPUS overrides it. Re-applied on a beat because steamwebhelper spawns
    # children continuously and each one inherits Steam's choice, not ours.
    if [ -n "${BL_CLIENT_CPUS:-}" ]; then
      step "client cores: $BL_CLIENT_CPUS"
      (
        for _ in $(seq 1 720); do
          for _p in $(pgrep -f 'steamwebhelper|steamrtarm64/steam|gamescope' 2>/dev/null); do
            taskset -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true
          done
          sleep 5
        done
      ) &
    fi

    step "registering the app's game library with Steam"
    bannerlator-steam-library "$steam_root" || true
    step "setting up the ARM64 Proton compatibility tool"
    bannerlator-steam-compat "$steam_root" || true
    # The user's own games, as non-Steam shortcuts under that tool; the list is written by the app
    # each session (empty = the app's earlier entries are removed, the client's own are kept).
    if [ -n "${BL_ADDED_GAMES:-}" ]; then
      step "adding the app's own games to the client's library"
      bannerlator-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
    fi
    # Steam runs a game's shared redistributables inside its prefix on first launch; marking
    # them as already run keeps those x86 installers from starting under emulation at all.
    bannerlator-seed-redists "$steam_root" || true
    # The compatibility tool runs Valve's arm64 Proton, which the client fetches like any depot.
    # Asked for on the command line the first time, so a Windows title has it before it is needed.
    if [ ! -f "$steam_root/steamapps/appmanifest_4427310.acf" ]; then
      step "asking the client to fetch the ARM64 Proton"
      set -- "$@" steam://install/4427310
      # Ported from Bannerlator (bannerlator-session, device-proven 2026-09-22). The client decides
      # a title's compatibility tool when the title is installed and keeps that decision for the
      # rest of its run, and the tool is set up before the client comes up. So once the depot has
      # fully landed, restart the client once - its own -shutdown, then the loop below, which knows
      # the marker - and every title installed from then on runs through the tool, with the
      # refreshed tool list and defaults, and no second launch by the user. A first run only: with
      # the depot present at session start nothing here is armed.
      (
        _bl_acf="$steam_root/steamapps/appmanifest_4427310.acf"
        for _ in $(seq 1 360); do
          sleep 10
          # StateFlags 4 = fully installed; a moment more for the client to finish its bookkeeping.
          if grep -q '"StateFlags"[[:space:]]*"4"' "$_bl_acf" 2>/dev/null; then
            sleep 10
            bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true
            step "compatibility layer installed: restarting the Steam client once"
            : > "$HOME/.bl-restart-once"
            "$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true
            exit 0
          fi
        done
      ) &
      # A client that starts signed out drops that request - on a fresh install nobody has signed
      # in yet - so the install dialog only appeared after the user restarted the client by hand.
      # With no remembered sign-in, ask again once the sign-in has happened: the client binary
      # started a second time with a URL hands it to the running instance and exits.
      if ! grep -q '"RememberPassword"[[:space:]]*"1"' "$steam_root/config/loginusers.vdf" 2>/dev/null; then
        (
          _bl_conn="$steam_root/logs/connection_log.txt"
          _bl_from=$(stat -c %s "$_bl_conn" 2>/dev/null || echo 0)
          for _ in $(seq 1 180); do
            sleep 10
            [ -f "$steam_root/steamapps/appmanifest_4427310.acf" ] && exit 0
            if tail -c +$((_bl_from + 1)) "$_bl_conn" 2>/dev/null | grep -q "RecvMsgClientLogOnResponse() : processing complete"; then
              sleep 15
              [ -f "$steam_root/steamapps/appmanifest_4427310.acf" ] && exit 0
              step "signed in: asking the client again for the ARM64 Proton"
              "$steam_root/steamrtarm64/steam" steam://install/4427310 >/dev/null 2>&1 || true
              exit 0
            fi
          done
        ) &
      fi
    fi
    # The tool is registered before the client starts and finds the depot at launch time, so
    # nothing here waits for a download. What can still change under a running client is which
    # titles are installed: a title installed this session gets the client's own per-app Proton
    # choice, which it cannot start. Refresh the per-app entries every so often; the registrar is
    # idempotent, so a repeat costs nothing.
    # The seeder has the same problem and a worse consequence. It marks the shared redistributables
    # as already run in each prefix that exists, and a game's prefix is not created until the
    # client launches it - so a title played for the first time this session was never covered, and
    # its install scripts run for real. That is exactly the hang the seeder exists to prevent: Need
    # for Speed Payback sat on "Running install script" with vcredist_x86.exe blocked in pipe_read,
    # holding both ends of a pipe nobody would write to, while the client waited for it to exit.
    # Seeding again on the same beat covers a prefix the moment it appears.
    # The seeder runs on a shorter beat than the registrar because it is racing something. The
    # client creates a prefix when it launches a title and runs that title's install scripts
    # moments later, so the seeder has only that gap to mark the redistributables as already run.
    # It stamps each prefix it has covered and skips it thereafter, so the extra passes cost
    # almost nothing.
    (
      beat=0
      for _ in $(seq 1 720); do
        sleep 5
        bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true
        beat=$((beat + 1))
        if [ "$((beat % 3))" -eq 0 ]; then
          bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true
        fi
      done
    ) &
    # Debug aid while the runtime is being brought up: an app id dropped in ~/.bl-autolaunch is
    # handed to the client as a rungameid URL, so a launch can be reproduced without touching the
    # screen. Removed once the runtime is stable.
    if [ -s "$HOME/.bl-autolaunch" ]; then
      _bl_app=$(cat "$HOME/.bl-autolaunch")
      # A bare app id runs the game; a full steam:// URL is passed through, which is how a
      # validate or an install is driven without touching the screen.
      case $_bl_app in
        steam://*) _bl_url=$_bl_app ;;
        *) _bl_url=steam://rungameid/$_bl_app ;;
      esac
      echo "== autolaunch $_bl_url"
      set -- "$@" "$_bl_url"
    fi
    # The client builds Settings > Internet and its connection indicator on NetworkManager, which
    # it looks for on the SYSTEM bus as it starts. This runtime has neither, so the page failed
    # ("StartScanningForNetworks is not a function") under a header that showed no connection even
    # while downloads ran. A system bus and a stand-in that reports Android's connection - and
    # refuses what it cannot do, since the app has no say over Wi-Fi - answer what the client asks.
    # The bus config is written here rather than shipped, so a runtime installed earlier gets this
    # from the app's copy of the session scripts without a re-host. (WinNative, maxjivi05, e7a224ae.)
    if command -v dbus-daemon >/dev/null 2>&1 && command -v bannerlator-netmanager >/dev/null 2>&1; then
      mkdir -p /run/dbus /usr/local/share/bannerlator
      cat > /usr/local/share/bannerlator/system-bus.conf <<'BUSCONF'
<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
 "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
  <type>system</type>
  <listen>unix:path=/run/dbus/system_bus_socket</listen>
  <auth>EXTERNAL</auth>
  <policy context="default">
    <allow user="*"/>
    <allow own="*"/>
    <allow send_destination="*"/>
    <allow receive_sender="*"/>
  </policy>
</busconfig>
BUSCONF
      rm -f /run/dbus/system_bus_socket
      dbus-daemon --config-file=/usr/local/share/bannerlator/system-bus.conf --nofork --nopidfile &
      system_bus=$!
      for _ in $(seq 1 50); do
        [ -S /run/dbus/system_bus_socket ] && break
        sleep 0.1
      done
      if [ -S /run/dbus/system_bus_socket ]; then
        bannerlator-netmanager &
        netmanager=$!
        echo "== network: system bus up, NetworkManager stand-in running"
      else
        echo "== network: the system bus did not come up; the client will show no connection"
      fi
    else
      echo "== network: no dbus-daemon or netmanager in this runtime; the client will show no connection"
    fi

    export STEAM_RUNTIME=1
    export LD_LIBRARY_PATH=$steam_root/steamrtarm64:$steam_root/steamrtarm64/panorama${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}
    export SDL_VIDEO_X11_DGAMOUSE=0
    # Native-Linux x86 games go through Valve's FEX compat tool, which runs FEX with FEX_PORTABLE=1
    # - that makes FEX look for its config next to the binary, while the tool writes one into the
    # app's compatdata instead. FEX therefore starts with no rootfs, every x86_64 library lookup
    # falls through to the host, and the guest shell dies on libreadline before the game is reached.
    # FEX_ROOTFS names it outright and is read whatever the config situation.
    [ -d /usr/share/guestos/fex-mesa ] && export FEX_ROOTFS=/usr/share/guestos/fex-mesa
    # The client exits 42 when it has updated itself and wants a fresh start (steam.sh does this).
    # BL_STEAM_UI=desktop = the client's desktop UI (a window, the library as a list), which the
    # desktop's launchers ask for; gamescope shows one window at a time and the game takes over.
    _bl_ui=(-gamepadui)
    [ "${BL_STEAM_UI:-}" = desktop ] && _bl_ui=()
    # Deck mode (BL_STEAMDECK=1, the Performance page): -steamdeck is the flag that puts the Quick
    # Access Menu, the performance overlay and the Deck settings pages on screen. Only -steamdeck,
    # never -steamos3: that one makes the client manage itself as SteamOS does - it calls
    # steamos-select-branch and opts the install into the steamdeck_stable client branch, writes
    # that to package/beta, and from then on the branch on disk loses to the -clientbeta below on
    # every launch; the updater finds "installed version 0", marks an update pending and exits 42
    # to apply it, which never happens, so the client restarts for ever - under a running game,
    # taking Steam Input with it each time. (Bannerlator 38996ea1, on device.) The SteamOS helpers
    # the client calls in this mode are staged as no-op stubs under /usr/bin.
    if [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_STEAM_UI:-}" != desktop ]; then
      _bl_ui+=(-steamdeck)
      # The QAM's Scaling Filter lists NVIDIA Image Scaling only when told gamescope has it (it does).
      export STEAM_GAMESCOPE_NIS_SUPPORTED=1
      echo "== steam: Deck mode on (-steamdeck)"
    fi
    # What a killed session leaves behind. Android ends sessions without warning (backgrounding, the
    # low-memory killer), and the client then finds .crash - opening its update UI on the spinner -
    # and the web helper's Singleton* locks in htmlcache, owned by a process that is gone, which can
    # leave the Gamepad UI blank. No client or helper is running at this point in a session.
    if ! pgrep -f 'steamwebhelper|steamrtarm64/steam' >/dev/null 2>&1; then
      _stale=""
      for _f in "$steam_root/.crash" "$steam_root/steam.pid" \
                "$steam_root/config/htmlcache/SingletonLock" "$steam_root/config/htmlcache/SingletonCookie" \
                "$steam_root/config/htmlcache/SingletonSocket"; do
        if [ -e "$_f" ] || [ -L "$_f" ]; then rm -f "$_f" && _stale="$_stale ${_f##*/}"; fi
      done
      [ -n "$_stale" ] && echo "== steam: cleared what an earlier session left:$_stale"
      unset _stale _f
    fi
    # The client branch. publicbeta is what every session has run on; steamdeck_publicbeta is the
    # channel Armada bootstraps its ARM64 client from. Whatever wrote package/beta, the branch on
    # disk has to agree with the one forced here - a disagreement is the restart loop above, and
    # it is silent apart from rc=42 in this log.
    # Checked before every start, not once: the client rewrites package/beta during its own run,
    # and a restart below (rc=42, or the one after the ARM64 Proton lands) would otherwise start it
    # again on the branch it just wrote.
    steam_channel=${BL_STEAM_CHANNEL:-publicbeta}
    steam_beta=$steam_root/package/beta
    echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*"
    # An update that cannot install asks for a restart (rc=42) every time; the count is capped so a
    # session can never again loop in silence, which reads as a menu opening and closing by itself.
    steam_updates=0
    while :; do
      rm -f "$HOME/.bl-restart-once"
      if [ -f "$steam_beta" ] && [ "$(cat "$steam_beta" 2>/dev/null)" != "$steam_channel" ]; then
        echo "== steam: client branch on disk was '$(cat "$steam_beta" 2>/dev/null)', set to '$steam_channel' to match -clientbeta"
        echo "$steam_channel" > "$steam_beta"
      fi
      step "starting the Steam client${BL_STEAM_UI:+ ($BL_STEAM_UI UI)}"
      "$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" -clientbeta "$steam_channel" \
        -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 \
        -cef-use-gl=angle -cef-use-angle=vulkan "$@"
      rc=$?
      echo "== steam exited rc=$rc"
      collect_steam_logs
      # The session asked for this exit (the ARM64 Proton landed): start again, without the URL
      # arguments, which were acted on. Not counted against the update cap.
      if [ -f "$HOME/.bl-restart-once" ]; then
        echo "== steam: restarting once so the new compatibility layer is in the client's lists"
        set --
        continue
      fi
      [ "$rc" -eq 42 ] || break
      steam_updates=$((steam_updates + 1))
      if [ "$steam_updates" -ge 3 ]; then
        echo "== steam: asked to restart for an update $steam_updates times and it is not installing; the session stops here rather than loop"
        break
      fi
      # The URL arguments were acted on the first time; a restart must not repeat them.
      set --
    done
    reap_orphans
    exit "$rc"
    ;;
  *)
    echo "bannerlator-session: unknown mode '$mode'" >&2
    exit 64
    ;;
esac
