#!/bin/bash
# Ring Out - Ver 1.0
#
# First run asks for a GameCube disc image you already have, then
# extracts it and recompiles its executable on this machine. Nothing
# game-derived ships with this package; everything personal is produced here
# and stays in this folder.

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

# Keep a log of the run. Launched from a file manager or a desktop entry there
# is no terminal to print to, so a crash otherwise leaves the window closing and
# nothing else -- which is exactly the position a "it crashes on the VS screen"
# report starts from. The Deck launcher has kept one for this reason; this one
# did not. Truncated per run, with the previous kept for comparison, because
# what matters is the attempt just made and the one before it.
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
echo "=== RingOut launch $(date -Is) ==="
echo "runtime : $(date -r "$HERE/bin/moderngekko-run" +%F\ %H:%M 2>/dev/null)"
for m in "$HERE"/bin/g*_recomp.so; do
    [ -e "$m" ] && echo "module  : $(basename "$m")  $(date -r "$m" +%F\ %H:%M)"
done
echo "session : ${XDG_SESSION_TYPE:-unset}   wayland: ${WAYLAND_DISPLAY:-unset}   x11: ${DISPLAY:-unset}"

# Only the libraries this host does NOT already provide go on the search path.
#
# Putting all of lib/ on LD_LIBRARY_PATH shadows the host's copy of every soname
# we ship, and the variable as a whole outranks the system paths, so ordering
# within it is not a fix. That matters because the process is not only ours: the
# Vulkan loader dlopens the host's Mesa driver into it, and Mesa then resolves
# libzstd, libz, libxcb, libX11, libwayland-client and friends against our
# copies rather than the ones it was built against. No driver loads, the loader
# has no ICD, and the run dies at "Failed to create Vulkan instance".
#
# That is not hypothetical: it is exactly how the Steam Deck build failed, and
# it only shows up when the user's distro differs from wherever lib/ was filled
# from -- so it would reach users of this package and not the person who built
# it. ldconfig is asked what is installed and only the remainder is linked into
# a private directory. RINGOUT_ALL_LIBS=1 forces the whole bundle back, which
# separates a library the host is missing from one whose bundled copy is wrong.
# 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"
    else
        HOSTLIBS="$(ldconfig -p 2>/dev/null | awk '{print $1}' | sort -u)"
        FALLBACK="$USER_DIR/lib-fallback"
        rm -rf "$FALLBACK"
        mkdir -p "$FALLBACK"
        for so in "$LIBDIR"/*.so*; do
            [ -e "$so" ] || continue
            name="$(basename "$so")"
            printf '%s\n' "$HOSTLIBS" | grep -qxF "$name" && continue
            ln -sf "$so" "$FALLBACK/$name"
        done
        LIB_PATH="$FALLBACK"
    fi
    export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$LIB_PATH"
fi

# Bundled post-processing filters (scanlines, CRT). Dolphin only searches
# <userdir>/Shaders, so they are installed there on launch. -n means 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

have_module() { ls "$HERE"/bin/g*_recomp.so >/dev/null 2>&1; }

# Ask for the disc image, preferring a graphical picker when one exists.
pick_iso() {
    if command -v kdialog >/dev/null; then
        kdialog --getopenfilename "$HOME" \
            "Disc images (*.iso *.gcm *.nkit.iso *.rvz)" \
            --title "Ring Out - select your game disc image" 2>/dev/null
    elif command -v zenity >/dev/null; then
        zenity --file-selection \
            --title="Ring Out - select your game disc image" 2>/dev/null
    else
        printf 'Path to your disc image: ' >&2
        read -r reply
        printf '%s' "$reply"
    fi
}

report() {
    if command -v kdialog >/dev/null; then
        kdialog --title "Ring Out" --error "$1" 2>/dev/null
    elif command -v zenity >/dev/null; then
        zenity --error --text="$1" 2>/dev/null
    fi
    echo "$1" >&2
}

# Accept the disc image as a plain argument, so `./RingOut game.nkit.iso` works
# from a terminal. Without this the launcher went straight to the graphical
# picker and reported "No disc image selected" even though a valid path was
# given on the command line.
ISO_ARG=""
case "${1:-}" in
    *.iso|*.ISO|*.gcm|*.GCM|*.rvz|*.RVZ|*.wbfs|*.WBFS|*.gcz|*.GCZ)
        if [ -f "$1" ]; then
            ISO_ARG="$1"
            shift          # do not forward it to the emulator
        fi
        ;;
esac

if ! have_module || [ ! -d "$HERE/game" ]; then
    echo "First run: this package contains no game data."
    echo "You need a GameCube disc image you already have."
    echo

    ISO="${ISO_ARG:-$(pick_iso)}"
    if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
        report "No disc image selected. Setup cancelled."
        exit 1
    fi

    # Run setup in a terminal when launched from a desktop icon, so the user can
    # watch it -- recompiling takes several minutes.
    if [ ! -t 1 ] && command -v konsole >/dev/null; then
        konsole --noclose -e "$HERE/setup.sh" "$ISO"
    elif [ ! -t 1 ] && command -v x-terminal-emulator >/dev/null; then
        x-terminal-emulator -e "$HERE/setup.sh" "$ISO"
    else
        "$HERE/setup.sh" "$ISO" || exit 1
    fi

    if ! have_module; then
        report "Setup did not finish. See the messages above."
        exit 1
    fi
fi

GAME_ARGS=""
case " $* " in
    *" --game "*) ;;
    *) [ -d "$HERE/game" ] && GAME_ARGS="--game $HERE/game" ;;
esac

# --- glibc fallback -----------------------------------------------------
# This build is produced on a bleeding-edge toolchain, so the binary asks for a
# newer glibc than conservative targets (SteamOS, older LTS distros) ship. glibc
# cannot be dropped into lib/ like the other libraries: the loader named in the
# binary's PT_INTERP (/lib64/ld-linux-x86-64.so.2) IS glibc and is version-locked
# to libc.so.6, so a host loader + bundled libc is a guaranteed crash.
#
# The only way that works is to use OUR loader and OUR libc together, which means
# invoking the loader explicitly. That is done ONLY when the host is too old --
# on an up-to-date system the host glibc is used exactly as before.
#
# Known risk if this path is taken: NSS modules (libnss_files/libnss_dns) are
# dlopen'd from the HOST and are built against the host glibc, so anything doing
# hostname or user lookups (netplay) may fall over. Local play generally does not
# touch them. If it crashes here, the real fix is building against an older
# glibc (Steam Runtime container), not more bundling.
# The floor of bin/moderngekko-run, which is built in the Debian 12 container
# (build-deck.sh), NOT on the developer's machine. This said 2.44 -- the glibc
# of the machine the runtime USED to be built on -- long after the build moved
# into the container.
#
# The cost of getting it wrong is not a warning. Anything between this number
# and the true floor takes the bundled-glibc path unnecessarily, and that path
# is the one the README warns about: the Vulkan loader dlopens the host's Mesa,
# which then resolves its own dependencies against the bundled copies and finds
# no driver. A Steam Deck (glibc 2.41) hit exactly this: "host glibc 2.41 is
# older than 2.44", bundled loader, and then no module. The module was fine.
#
# package-dist.sh now asserts this matches the shipped binary, so it cannot
# drift again.
# --- 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.
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 "  re-run ./setup.sh with your disc to restore it." >&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/tools/dolrecomp" extract "$RESTORE_ISO" "$HERE/game.restore-tmp" 2>/dev/null ||
           "$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

NEED_GLIBC=2.36
host_glibc="$(ldd --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+' | tail -1)"
LOADER="$HERE/libc-fallback/ld-linux-x86-64.so.2"

if [ -n "$host_glibc" ] && [ -x "$LOADER" ] && \
   [ "$(printf '%s\n%s\n' "$NEED_GLIBC" "$host_glibc" | sort -V | head -1)" != "$NEED_GLIBC" ]; then
    echo "Ring Out: host glibc $host_glibc is older than $NEED_GLIBC - using bundled glibc." >&2
    # $LIB_PATH, not $HERE/lib: on this path the bundled glibc genuinely has to
    # win, but the rest of the bundle should still defer to the host for
    # anything the host already has, for the Mesa reason above.
    exec "$LOADER" --library-path "$HERE/libc-fallback:$LIB_PATH:$LD_LIBRARY_PATH:/usr/lib:/usr/lib64:/lib:/lib64" \
        "$HERE/bin/moderngekko-run" --user-dir "$USER_DIR" $GAME_ARGS "$@"
fi

exec "$HERE/bin/moderngekko-run" --user-dir "$USER_DIR" $GAME_ARGS "$@"
