#!/bin/bash
# Ring Out - Ver 1.0 : Steam Deck / SteamOS build
#
# Everything here is prebuilt. Unlike the desktop redistributable there is no
# setup step, no toolchain and nothing to compile: SteamOS ships compilers
# without the C library headers and mounts /usr read-only, so an on-device
# build needs a distrobox container. The runtime and the recompiled module were
# both built elsewhere against an old glibc instead.
#
#   runtime : glibc 2.36 (SteamOS ships ~2.37, and binaries run forward only)
#   module  : -march=x86-64-v3, which the Deck's Zen 2 supports
#
# Add to Steam as a non-Steam game and launch from Game Mode, or run directly.

HERE="$(dirname "$(readlink -f "$0")")"

USER_DIR="$HERE/userdata"
mkdir -p "$USER_DIR"

# One instance per install. Two at once (a double launch is easy from a desktop
# icon or Steam) write the same shader-cache files together; the blob they leave
# behind is corrupt, and every later launch segfaults in the Vulkan driver
# replaying it until the cache is deleted. Seen on the Deck 2026-09-25: two
# starts in the same second, then crash after crash. The lock sits on fd 9,
# which the game inherits, so it is held exactly as long as the game runs.
# Taken BEFORE the log below is rotated, so a refused launch cannot clobber the
# running game's launch.log either.
if command -v flock >/dev/null 2>&1; then
    exec 9>"$USER_DIR/.instance.lock"
    if ! flock -n 9; then
        echo "Ring Out is already running from $HERE -- not starting a second copy." >&2
        command -v notify-send >/dev/null 2>&1 &&
            notify-send "Ring Out" "Already running -- not starting a second copy." 2>/dev/null
        exit 1
    fi
fi

# Steam discards stdout and stderr for a non-Steam shortcut, and Game Mode has
# no terminal to print to in any case, so a failed launch shows up only as the
# game opening and closing again with no reason given anywhere. Mirror the lot
# to a log that outlives the process. Truncated per run: what matters is the
# attempt just made, and the previous one is kept for comparison.
LOG="$USER_DIR/launch.log"
[ -f "$LOG" ] && mv -f "$LOG" "$LOG.prev"
if [ -t 1 ]; then
    exec > >(tee "$LOG") 2>&1
else
    exec > "$LOG" 2>&1
fi

# Only the libraries the host does NOT provide go on the search path.
#
# Ordering within LD_LIBRARY_PATH was never the issue: the variable as a whole
# outranks the system paths, so pointing it at lib/ shadowed the host's copy of
# every soname we ship no matter where in the list it sat. Appending changed
# nothing when the variable was otherwise empty, which is exactly the case when
# launching from a terminal.
#
# That matters because the process is not only ours. The Vulkan loader dlopens
# the host's Mesa driver into it, and Mesa then resolved libzstd, libz, libxcb,
# libX11, libX11-xcb, libwayland-client, libffi, libudev, libdbus-1 and
# libXext -- ten libraries SteamOS already has -- against Debian 12 copies it
# was not built against. No driver loads, the loader has no ICD, and
# vkCreateInstance fails:
#
#     [alert] Warning: Failed to create Vulkan instance.
#     [alert] Warning: Failed to initialize video backend!
#
# So the set is computed per host rather than assumed: ask ldconfig what is
# already installed, and link only the remainder into a private directory. On
# SteamOS that leaves a handful of genuinely Debian-specific libraries instead
# of all 66. Set RINGOUT_ALL_LIBS=1 to force the old behaviour of using the
# whole bundle, which is useful for telling a missing library apart from an
# incompatible one.
# The game's own banner and memory-card icon, extracted from the disc and saves
# the player supplied. Nothing here ships -- it belongs to the publisher -- so
# it is generated locally, on first launch once a game is present.
#
# Runs only when the output is missing, so it costs nothing on a normal launch.
# The icon comes from a .gci and therefore does not exist until the player has
# saved at least once; deleting art/icon.png re-runs this and picks it up.
if [ -x "$HERE/tools/gc-art.py" ] && [ -d "$HERE/game" ] \
   && [ ! -f "$HERE/art/icon.png" ] && command -v python3 >/dev/null 2>&1; then
    python3 "$HERE/tools/gc-art.py" "$HERE" >/dev/null 2>&1 || true
fi

LIBDIR="$HERE/lib"
LIB_PATH=""
if [ -d "$LIBDIR" ]; then
    if [ "${RINGOUT_ALL_LIBS:-0}" = "1" ] || ! command -v ldconfig >/dev/null 2>&1; then
        LIB_PATH="$LIBDIR"
        LIB_NOTE="whole bundle"
    else
        HOSTLIBS="$(ldconfig -p 2>/dev/null | awk '{print $1}' | sort -u)"
        FALLBACK="$USER_DIR/lib-fallback"
        rm -rf "$FALLBACK"
        mkdir -p "$FALLBACK"
        kept=0
        dropped=0
        for so in "$LIBDIR"/*.so*; do
            [ -e "$so" ] || continue
            name="$(basename "$so")"
            if printf '%s\n' "$HOSTLIBS" | grep -qxF "$name"; then
                dropped=$((dropped + 1))
                continue
            fi
            ln -sf "$so" "$FALLBACK/$name"
            kept=$((kept + 1))
        done
        LIB_PATH="$FALLBACK"
        LIB_NOTE="$kept bundled, $dropped left to the host"
    fi
    export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$LIB_PATH"
fi

# The environment is the difference between a launch that works from a terminal
# and the same launch failing under Steam, so record the parts that decide it.
echo "=== RingOut launch $(date -Is) ==="
echo "libraries    : ${LIB_NOTE:-no lib/ directory}"
echo "session      : ${XDG_SESSION_TYPE:-unset}"
echo "wayland      : ${WAYLAND_DISPLAY:-unset}"
echo "x11 display  : ${DISPLAY:-unset}"
echo "gamescope    : ${GAMESCOPE_WAYLAND_DISPLAY:-unset}"
echo "steam        : ${SteamEnv:-${STEAM_COMPAT_DATA_PATH:-not set}}"
echo "LD_PRELOAD   : ${LD_PRELOAD:-unset}"
echo "LD_LIBRARY_PATH: $LD_LIBRARY_PATH"

# vkCreateInstance fails for two quite different reasons and the environment
# tells them apart. Either no driver could be loaded, or a LAYER could not --
# and Steam injects its overlay layer into every instance a game creates, so a
# game that runs from a terminal and fails under Steam is usually a layer, not
# the driver. The loader treats a layer it cannot load as fatal to the instance.
echo "VK_ICD_FILENAMES : ${VK_ICD_FILENAMES:-unset}"
echo "VK_DRIVER_FILES  : ${VK_DRIVER_FILES:-unset}"
echo "VK_LAYER_PATH    : ${VK_LAYER_PATH:-unset}"
echo "VK_INSTANCE_LAYERS: ${VK_INSTANCE_LAYERS:-unset}"
echo "VK_LOADER_LAYERS_ENABLE: ${VK_LOADER_LAYERS_ENABLE:-unset}"
echo "steam overlay layer: ${DISABLE_VK_LAYER_VALVE_steam_overlay_1:+disabled}${DISABLE_VK_LAYER_VALVE_steam_overlay_1:-active}"

# RINGOUT_DEBUG=1 turns on the Vulkan loader's own tracing, which names the
# driver or layer that failed instead of leaving "failed to create instance" as
# the whole story. Off by default: it is very loud.
if [ "${RINGOUT_DEBUG:-0}" = "1" ]; then
    export VK_LOADER_DEBUG=all
    echo "debug        : VK_LOADER_DEBUG=all"
fi

# Which backend the config actually asks for, read from the file this launcher
# passes as --user-dir. Editing the wrong copy of Dolphin.ini looks identical to
# the setting being ignored, and an unknown name is worse than either: the
# lookup in ActivateBackend() returns silently and the default is used, so a
# misspelling reports nothing at all. The flag below outranks this file.
INI="$USER_DIR/Config/Dolphin.ini"
if [ -f "$INI" ]; then
    echo "GFXBackend   : $(grep -m1 '^GFXBackend' "$INI" | cut -d= -f2- | tr -d ' ')  (in $INI)"
else
    echo "GFXBackend   : no Dolphin.ini yet, the default backend will be used"
fi
echo "  to override: pass  -v OGL  or  -v Vulkan  to this launcher"
echo

# Bundled post-processing filters live where Dolphin actually looks for them.
# -n so a filter the user has edited is never overwritten.
if [ -d "$HERE/shaders" ]; then
    mkdir -p "$USER_DIR/Shaders"
    cp -n "$HERE/shaders"/*.glsl "$USER_DIR/Shaders/" 2>/dev/null || true
fi

# Cheat lists, same pattern: shipped read-only beside the launcher, installed
# where Dolphin reads game inis. -n so codes you have enabled, or codes you have
# added yourself, survive an update -- the CHEATS tab writes to this copy.
if [ -d "$HERE/gamesettings" ]; then
    mkdir -p "$USER_DIR/GameSettings"
    cp -n "$HERE/gamesettings"/*.ini "$USER_DIR/GameSettings/" 2>/dev/null || true
fi

# Seed the frontend config before the runtime can, because the runtime's own
# default is wrong for this machine: it writes resolution=1920x1080, which is
# EFB scale 3 -- a 1920x1584 render target. The Deck's panel is 1280x800, and
# the game is a 4:3 projection, so at most 1067x800 of that is ever seen. Scale
# 3 is roughly three times the pixels the display can show, paid for in frame
# time and battery for nothing visible.
#
# 1280x720 is scale 2 (1280x1056), still comfortably above the panel in both
# axes, so this is not a quality trade -- it is dropping work with no output.
# Written only when absent, so a resolution chosen in the VIDEO tab survives.
if [ ! -f "$USER_DIR/config.ini" ]; then
    cat > "$USER_DIR/config.ini" <<'CONFIG'
# ModernGekko frontend settings
# This is Dolphin's internal render target, not the window size.
[Video]
resolution=1280x720
show_fps_in_title=true
[Input]
[Netplay]
nickname=Player
address=127.0.0.1
port=2626
buffer=auto
CONFIG
fi

# --- game-data restore ------------------------------------------------------
# The MODS tab can ask for the disc's own game data to be put back after a
# character skin has patched root.olk. It only writes the request; the work
# happens HERE, before the runtime starts, because root.olk is open and mapped
# for the whole session and rewriting 590 MB underneath a running game is not
# something a pause menu should attempt.
#
# Re-extracting from the player's own image rather than keeping a pristine copy
# is what stops this feature costing 590 MB of disk on every install for a thing
# most players never do.
#
# This package ships no tools/ directory -- it is prebuilt and carries no
# toolchain -- so the runtime's own extractor does the work here. It reads
# more disc formats than the recompiler's extractor anyway.
RESTORE_REQUEST="$USER_DIR/restore-game-data.request"
if [ -f "$RESTORE_REQUEST" ]; then
    RESTORE_ISO="$(head -1 "$RESTORE_REQUEST" 2>/dev/null)"
    echo "Ring Out: restoring original game data from $RESTORE_ISO"
    if [ ! -f "$RESTORE_ISO" ]; then
        echo "  that disc image is not there any more - leaving the game data alone." >&2
        echo "  put it back, or re-extract it yourself with:" >&2
        echo "    ./bin/moderngekko-run --extract /path/to/disc.iso ./game" >&2
        rm -f "$RESTORE_REQUEST"
    else
        # Extract beside the real thing and swap, so an interrupted or failed
        # extraction leaves the playable game/ in place rather than half a disc.
        rm -rf "$HERE/game.restore-tmp"
        if "$HERE/bin/moderngekko-run" --extract "$RESTORE_ISO" "$HERE/game.restore-tmp"; then
            rm -rf "$HERE/game.restore-old"
            mv "$HERE/game" "$HERE/game.restore-old" &&                 mv "$HERE/game.restore-tmp" "$HERE/game" &&                 rm -rf "$HERE/game.restore-old"
            # The cached hash describes the file that was just replaced.
            rm -f "$USER_DIR/game-data-hash.txt"
            rm -f "$RESTORE_REQUEST"
            echo "  done - game data matches the disc again."
        else
            rm -rf "$HERE/game.restore-tmp"
            echo "  could not read that disc image - leaving the game data alone." >&2
            rm -f "$RESTORE_REQUEST"
        fi
    fi
fi

# --- skin install (AFTER restore: restore re-extracts the game, so a skin
# written before it would be thrown away) -----------------------------------------------------------
# The MODS tab queues a character skin here rather than writing it itself:
# root.olk is open and mapped for the whole session. Each line is
# "<offset> <size> <path to .dtp>", resolved by the game against the container
# index, so this only has to place bytes -- it does no parsing and cannot pick
# the wrong slot on its own.
#
# Same-size only, by construction: the offset and size came from an entry whose
# size already matched. A skin that needs the container rebuilt never gets here.
MOD_INSTALL_REQUEST="$USER_DIR/mod-install.request"
if [ -f "$MOD_INSTALL_REQUEST" ] && [ -d "$HERE/game" ]; then
    echo "Ring Out: installing queued skins"
    if python3 - "$MOD_INSTALL_REQUEST" "$HERE/game/files/root.olk" <<'PYEOF'
import os, sys
req, olk = sys.argv[1], sys.argv[2]
total = os.path.getsize(olk)
applied = 0
for line in open(req):
    line = line.strip()
    if not line:
        continue
    off, size, path = line.split(" ", 2)
    off, size = int(off), int(size)
    if not os.path.isfile(path) or os.path.getsize(path) != size:
        print(f"  skipped {os.path.basename(path)}: size no longer matches its slot")
        continue
    if off + size > total:
        print(f"  skipped {os.path.basename(path)}: outside the archive")
        continue
    with open(path, "rb") as f:
        payload = f.read()
    with open(olk, "r+b") as f:
        f.seek(off)
        f.write(payload)
    print(f"  installed {os.path.basename(path)}")
    applied += 1
print(f"  {applied} skin(s) written")
PYEOF
    then
        # The cached hash describes the file as it was before these writes.
        rm -f "$USER_DIR/game-data-hash.txt"
    else
        echo "  could not install the queued skins" >&2
    fi
    rm -f "$MOD_INSTALL_REQUEST"
fi

# Pick the module that MATCHES the disc in game/, not whatever sorts first.
# With a US and a PAL module both present, `ls | head -1` chose gGRSEAF while
# game/ held PAL -- a mismatch the runtime then rejects, for reasons no player
# could be expected to work out. Build a second region and you had it.
DISC_ID="$(head -c 6 "$HERE/game/sys/boot.bin" 2>/dev/null || true)"
MODULE=""
if [ -n "$DISC_ID" ] && [ -f "$HERE/bin/g${DISC_ID}_recomp.so" ]; then
    MODULE="$HERE/bin/g${DISC_ID}_recomp.so"
else
    MODULE="$(ls "$HERE"/bin/g*_recomp.so 2>/dev/null | head -1)"
    if [ -n "$MODULE" ] && [ -n "$DISC_ID" ]; then
        echo "Ring Out: no module for $DISC_ID; trying $(basename "$MODULE")." >&2
        echo "          If this fails, build one from your disc -- see README.txt." >&2
    fi
fi
if [ -z "$MODULE" ]; then
    echo "No recompiled module in bin/. This package is incomplete." >&2
    exit 1
fi
if [ ! -d "$HERE/game" ]; then
    echo "No game data in game/. This package is incomplete." >&2
    exit 1
fi

# The Deck's screen is 1280x800 and gamescope owns the display in Game Mode, so
# fullscreen is left to Steam rather than forced here.
# Not exec, so the exit status can be recorded. A runtime that dies during video
# init and one that never started at all look identical from Steam's side.
"$HERE/bin/moderngekko-run" \
    --user-dir "$USER_DIR" \
    --game "$HERE/game" \
    --module "$MODULE" \
    "$@"
status=$?

echo
if [ $status -ne 0 ]; then
    echo "[launcher] moderngekko-run exited with status $status"
    # 139 is SIGSEGV and 134 SIGABRT once the shell has folded the signal in.
    case $status in
        139) echo "[launcher] that is a segfault" ;;
        134) echo "[launcher] that is an abort" ;;
    esac
else
    echo "[launcher] exited cleanly"
fi
exit $status
