#!/bin/bash # services/wolf.sh — Cloud gaming via Moonlight (Games-on-Whales Wolf). # Part of the modular post-install system (sourced by setup.sh). # # Can also be run standalone on any machine: # sudo bash wolf.sh # (Docker must already be installed when run standalone) # # Self-hosted Moonlight streaming server. One Wolf container spins up app # containers (ES-DE/RetroArch, Steam, Lutris, Firefox, full desktop) on demand, # with virtual displays and virtual gamepads — no monitor, no dummy plug. # Stream to any Moonlight client (TV, phone, PC, Fire TV stick, etc.). # # Ported from setup-wolf.sh. The dispatcher runs as root (require_root), so the # original's refuse-root check and sudo prefixes are dropped. The wolf-pair # helper service is dropped (it depended on repo files we don't ship); the # `./manage.sh pin` command replaces it. # ── Standalone bootstrap ────────────────────────────────────────────────────── # Detected when the script is executed directly rather than sourced by setup.sh. # Sets up helpers and globals, then defers execution until after the function # definition at the bottom of this file. if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" _COMMON="$_SELF_DIR/../lib/common.sh" if [[ -f "$_COMMON" ]]; then # Full repo present — use the real helpers (picks up ~/docker/.config too) # shellcheck source=../lib/common.sh source "$_COMMON" else # One-off copy — inline minimal stubs so the script works without the repo log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } require_docker() { command -v docker &>/dev/null || { log_error "Docker not found. Install it first:" log_error " curl -fsSL https://get.docker.com | sudo sh" return 1 } docker compose version &>/dev/null || { log_error "Docker Compose plugin missing:" log_error " sudo apt-get install -y docker-compose-plugin" return 1 } } ensure_docker_dir_ownership() { chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } ensure_docker_dir_ownership() { chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } read -r -p " $_q " _r eval "$_var='${_r:-$_def}'" } prompt_yn() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } read -r -p " $_q " _r eval "$_var='${_r:-$_def}'" } configure_caddy_for_service() { local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" local _display_port="${_upstream##*:}" local _mode="none" [[ -d "$_caddy_dir" ]] && _mode="local" [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" [[ "$_mode" == "none" ]] && { log_info "Access $_name directly on port $_display_port." return 0 } echo "" local _do_caddy="" if [[ "$_mode" == "remote" ]]; then log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." log_info "A snippet file will be saved to ~/docker/caddy-snippets/." fi read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy [[ "${_do_caddy,,}" == "y" ]] || { log_info "Skipping — access at: http://localhost:$_display_port" return 0 } local _default_domain="" if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then _default_domain="${_subdomain}.${SITE_DOMAIN}" log_info "Default: $_default_domain" fi local _domain="" read -r -p " Domain [${_default_domain:-required}]: " _domain _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } local _block_upstream="$_upstream" if [[ "$_mode" == "remote" ]]; then _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi local _site_block _site_block="$(cat << CBLOCK # $_name ${_domain} { reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "SAMEORIGIN" Referrer-Policy "strict-origin-when-cross-origin" } log { output file /var/log/caddy/${_domain}.log format json } ${_extra} } CBLOCK )" if [[ "$_mode" == "local" ]]; then if [[ -f "$_caddyfile" ]]; then local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" cp "$_caddyfile" "$_bk" log_info "Backed up Caddyfile to $(basename "$_bk")" else touch "$_caddyfile" fi if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then log_warning "$_domain already in Caddyfile" local _ow="" read -r -p " Overwrite? [y/N]: " _ow [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } sed -i "/^${_domain}/,/^}/d" "$_caddyfile" fi printf '%s\n' "$_site_block" >> "$_caddyfile" log_success "Added $_domain to Caddyfile" docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then log_success "$_name accessible at: https://$_domain" else log_warning "Reload failed — check: docker logs caddy" log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" fi else local _snippet_dir="$DOCKER_DIR/caddy-snippets" local _snippet_file="$_snippet_dir/${_subdomain}.caddy" mkdir -p "$_snippet_dir" printf '%s\n' "$_site_block" > "$_snippet_file" chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true log_success "Snippet saved: $_snippet_file" log_info "Copy to Caddy machine:" log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } write_readme() { local _dir="$1"; shift mkdir -p "$_dir" cat > "$_dir/README.md" } fi # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # ($HOME under sudo is /root, not the real user's home) ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" DRY_RUN="${DRY_RUN:-false}" UNATTENDED="${UNATTENDED:-false}" SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 fi # ───────────────────────────────────────────────────────────────────────────── register_service wolf gaming "Cloud gaming via Moonlight (Games-on-Whales Wolf)" 47989 install_wolf() { require_docker || return 1 local WOLF_DIR="$DOCKER_DIR/wolf" # Moonlight / Wolf default ports local WOLF_PORTS_TCP=(47984 47989 48010) local WOLF_PORTS_UDP=(47999 48100 48200) cat << "EOF" ╔═══════════════════════════════════════════════════════╗ ║ ║ ║ WOLF CLOUD GAMING SETUP ║ ║ Self-hosted Moonlight streaming (Games-on-Whales) ║ ║ ║ ╚═══════════════════════════════════════════════════════╝ EOF echo "" # ── Dry-run summary (do nothing that touches hardware/docker/apt) ───────── if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Wolf install would:" echo " - Check for an NVIDIA GPU with driver >= 530 and detect its render node" echo " - Ensure nvidia-drm modeset=1 (modprobe.d + GRUB/systemd-boot), may need reboot" echo " - Install the NVIDIA Container Toolkit if missing" echo " - Set up uinput/uhid modules + virtual-input udev rules" echo " - Build the NVIDIA driver volume (nvidia-driver-vol) + copy CUDA/NVENC libs" echo " - Detect LAN / Tailscale IP for Wolf to advertise" echo " - Write $WOLF_DIR/docker-compose.yml and $WOLF_DIR/manage.sh" echo " - Open Moonlight UFW ports: TCP ${WOLF_PORTS_TCP[*]} / UDP ${WOLF_PORTS_UDP[*]}" echo " - Start Wolf and inject Steam + EmulationStation app profiles" return 0 fi # ── OS / Docker Compose sanity ──────────────────────────────────────────── if [ -f /etc/os-release ]; then . /etc/os-release log_success "OS: $PRETTY_NAME" fi if ! docker compose version &>/dev/null; then log_error "Docker Compose v2 not found. Install: apt-get install docker-compose-plugin" return 1 fi log_success "Docker found (Compose: $(docker compose version --short))" # ── NVIDIA checks ───────────────────────────────────────────────────────── local HAS_NVIDIA=false DRIVER_VER GPU_NAME DRIVER_MAJOR if command -v nvidia-smi &>/dev/null && nvidia-smi &>/dev/null 2>&1; then HAS_NVIDIA=true DRIVER_VER=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader | head -n1) GPU_NAME=$(nvidia-smi --query-gpu=name --format=csv,noheader | head -n1) log_success "NVIDIA GPU: $GPU_NAME (driver $DRIVER_VER)" # Wolf requires driver >= 530.30.02 DRIVER_MAJOR=$(echo "$DRIVER_VER" | cut -d. -f1) if [ "$DRIVER_MAJOR" -lt 530 ] 2>/dev/null; then log_error "Wolf needs NVIDIA driver >= 530.30.02 (you have $DRIVER_VER)." log_error "Update: apt-get install nvidia-driver-535 && reboot" return 1 fi else log_error "No working NVIDIA GPU detected (nvidia-smi failed)." log_error "Wolf needs a working NVIDIA driver for hardware encoding." log_error "Install one, reboot, and re-run this module." return 1 fi # ── Detect the NVIDIA DRM render node ───────────────────────────────────── # Wolf reads WOLF_RENDER_NODE (default /dev/dri/renderD128) to detect the GPU # vendor, then only selects an encoder whose vendor matches. On systems with # both an Intel iGPU and an NVIDIA card, renderD128 is usually the Intel GPU, # so Wolf detects "Intel", picks VA-API, and never tries NVENC. # # 0x10de is NVIDIA's PCI vendor ID. To pick the exact card the driver manages # (unambiguous on multi-GPU hosts) we cross-check each candidate's PCI bus # address against the bus IDs nvidia-smi reports. local WOLF_RENDER_NODE="" NV_BUS_SHORT _rnode _dev _pci_short NV_BUS_SHORT=$(nvidia-smi --query-gpu=pci.bus_id --format=csv,noheader 2>/dev/null \ | awk -F: '{print $(NF-1)":"$NF}' | tr 'A-F' 'a-f') for _rnode in /dev/dri/renderD*; do [ -e "$_rnode" ] || continue _dev="/sys/class/drm/$(basename "$_rnode")/device" [ "$(cat "$_dev/vendor" 2>/dev/null)" = "0x10de" ] || continue # NVIDIA vendor _pci_short=$(basename "$(readlink -f "$_dev" 2>/dev/null)" | awk -F: '{print $(NF-1)":"$NF}') if [ -n "$NV_BUS_SHORT" ]; then if printf '%s\n' "$NV_BUS_SHORT" | grep -qix "$_pci_short"; then WOLF_RENDER_NODE="$_rnode"; break fi else WOLF_RENDER_NODE="$_rnode"; break fi done if [ -n "$WOLF_RENDER_NODE" ]; then log_success "NVIDIA render node detected: $WOLF_RENDER_NODE (Wolf will use it for NVENC)" else WOLF_RENDER_NODE="/dev/dri/renderD128" log_warning "Could not match an NVIDIA render node to nvidia-smi — defaulting to $WOLF_RENDER_NODE" log_warning "If Wolf logs 'Using h265 encoder: va', set WOLF_RENDER_NODE manually to your NVIDIA card." log_warning "List candidates with: for n in /dev/dri/renderD*; do echo \$n \$(cat /sys/class/drm/\$(basename \$n)/device/vendor); done" fi # ── nvidia-drm modeset=1 (required for Wolf's virtual displays) ─────────── # Primary method: modprobe.d (bootloader-agnostic). Also set it in GRUB and # systemd-boot cmdline as a backup. Check sysfs (older: Y/N, newer 5xx: 1/0) # and /proc/cmdline — if either confirms modeset=1 we are good. local MODESET MODESET=$(cat /sys/module/nvidia_drm/parameters/modeset 2>/dev/null | tr -d '[:space:]') if grep -q "nvidia-drm.modeset=1" /proc/cmdline 2>/dev/null; then MODESET="1" # cmdline is authoritative — module may just not be loaded yet fi if [[ "$MODESET" != "Y" && "$MODESET" != "1" && "$MODESET" != "2" ]]; then echo "" log_warning "Kernel module nvidia-drm is NOT loaded with modeset=1." log_warning "Wolf needs this to create virtual displays." echo "" local ENABLE_MODESET="y" if [ "$UNATTENDED" = true ]; then log_warning "Unattended mode — enabling nvidia-drm modeset=1 (no auto-reboot)." ENABLE_MODESET="y" else read -p "Enable nvidia-drm modeset=1 now (requires reboot)? (y/n) [y]: " -n 1 -r; echo ENABLE_MODESET="${REPLY:-y}" fi if [[ "$ENABLE_MODESET" =~ ^[Yy]$ ]]; then # ── Method 1: modprobe.d (bootloader-agnostic, most reliable) ── local MODPROBE_CONF="/etc/modprobe.d/nvidia-drm-modeset.conf" if ! grep -qs "modeset=1" "$MODPROBE_CONF" 2>/dev/null; then echo "options nvidia-drm modeset=1" | tee "$MODPROBE_CONF" >/dev/null log_success "Written: $MODPROBE_CONF" fi # Rebuild initramfs so the option is baked in if command -v update-initramfs &>/dev/null; then log_info "Rebuilding initramfs (this takes ~30 s)..." update-initramfs -u -k all fi # ── Method 2: GRUB (if present) ── local GRUB_FILE="/etc/default/grub" if [ -f "$GRUB_FILE" ] && ! grep -q "nvidia-drm.modeset=1" "$GRUB_FILE"; then sed -i \ 's/\(GRUB_CMDLINE_LINUX_DEFAULT="[^"]*\)"/\1 nvidia-drm.modeset=1"/' \ "$GRUB_FILE" if command -v update-grub &>/dev/null; then update-grub 2>/dev/null log_success "Added nvidia-drm.modeset=1 to GRUB" fi fi # ── Method 3: systemd-boot (Ubuntu 24.04+ EFI installs) ── local SBOOT_CONF SBOOT_CONF=$(find /boot/loader/entries/ -name "*.conf" 2>/dev/null | head -1) if [ -n "$SBOOT_CONF" ] && ! grep -q "nvidia-drm.modeset=1" "$SBOOT_CONF"; then sed -i 's/\(^options .*\)/\1 nvidia-drm.modeset=1/' "$SBOOT_CONF" log_success "Added nvidia-drm.modeset=1 to systemd-boot entry: $(basename "$SBOOT_CONF")" fi echo "" log_warning "A REBOOT is required. Re-run this module after rebooting." if [ "$UNATTENDED" = true ]; then log_warning "Unattended mode — skipping reboot. Reboot manually, then re-run: sudo ./setup.sh wolf" return 0 fi read -p "Reboot now? (y/n) [y]: " -n 1 -r; echo if [[ ${REPLY:-y} =~ ^[Yy]$ ]]; then reboot fi return 0 else log_warning "Continuing without modeset=1 — Wolf may fail to start virtual displays." fi else log_success "nvidia-drm modeset is active (sysfs reports: $MODESET)" fi # ── NVIDIA Container Toolkit (bootstraps the driver volume build) ───────── if ! command -v nvidia-container-cli &>/dev/null; then log_warning "nvidia-container-cli not found — installing NVIDIA Container Toolkit..." curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \ gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg curl -sL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \ sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \ tee /etc/apt/sources.list.d/nvidia-container-toolkit.list >/dev/null apt-get update apt-get install -y nvidia-container-toolkit nvidia-ctk runtime configure --runtime=docker systemctl restart docker log_success "NVIDIA Container Toolkit installed" fi # ── Virtual input devices (gamepads) ────────────────────────────────────── log_info "Setting up virtual gamepad support..." # uinput / uhid kernel modules if [ ! -e /dev/uinput ]; then log_info "Loading uinput kernel module..." modprobe uinput || log_warning "Could not load uinput module" fi [ -e /dev/uhid ] || modprobe uhid 2>/dev/null || true # Make uinput load at boot if [ ! -f /etc/modules-load.d/uinput.conf ]; then echo "uinput" | tee /etc/modules-load.d/uinput.conf >/dev/null fi # udev rules so Wolf can access virtual input devices local UDEV_RULES="/etc/udev/rules.d/85-wolf-virtual-inputs.rules" if [ ! -f "$UDEV_RULES" ]; then log_info "Installing Wolf virtual-input udev rules..." tee "$UDEV_RULES" >/dev/null << 'UDEV' # Wolf virtual input devices KERNEL=="uinput", SUBSYSTEM=="misc", MODE="0660", GROUP="input", OPTIONS+="static_node=uinput", TAG+="uaccess" KERNEL=="uhid", GROUP="input", MODE="0660", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{name}=="Wolf PS5 (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9" SUBSYSTEMS=="input", ATTRS{name}=="Wolf X-Box One (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9" SUBSYSTEMS=="input", ATTRS{name}=="Wolf PS5 (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9" SUBSYSTEMS=="input", ATTRS{name}=="Wolf gamepad (virtual) motion sensors", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9" SUBSYSTEMS=="input", ATTRS{name}=="Wolf Nintendo (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9" UDEV udevadm control --reload-rules && udevadm trigger log_success "udev rules installed" else log_info "Wolf udev rules already present" fi # ── Build the NVIDIA driver volume (GoW recommended 'manual' method) ────── # More stable than the container-toolkit method for Wolf. The volume holds # userspace driver files matching the host kernel driver, mounted into app # containers. log_info "Building NVIDIA driver volume for Wolf (matches host driver $DRIVER_VER)..." local NV_KVER VOL_HAS NV_KVER=$(cat /sys/module/nvidia/version 2>/dev/null || echo "$DRIVER_VER") if docker volume ls --format '{{.Name}}' | grep -q '^nvidia-driver-vol$'; then # Check if the volume matches the current driver; if not, rebuild VOL_HAS=$(docker run --rm -v nvidia-driver-vol:/usr/nvidia alpine \ sh -c 'ls /usr/nvidia/lib 2>/dev/null | grep -o "libnvidia-glcore.so.[0-9.]*" | head -1' 2>/dev/null || echo "") if echo "$VOL_HAS" | grep -q "$NV_KVER"; then log_success "nvidia-driver-vol already matches driver $NV_KVER" else log_warning "Driver volume is stale — rebuilding for $NV_KVER" docker volume rm nvidia-driver-vol >/dev/null 2>&1 || true fi fi if ! docker volume ls --format '{{.Name}}' | grep -q '^nvidia-driver-vol$'; then log_info "Building gow/nvidia-driver:latest (driver $NV_KVER)..." curl -fsSL https://raw.githubusercontent.com/games-on-whales/gow/master/images/nvidia-driver/Dockerfile \ | docker build -t gow/nvidia-driver:latest -f - --build-arg NV_VERSION="$NV_KVER" . \ || { log_error "Failed to build the NVIDIA driver image."; return 1; } log_info "Populating nvidia-driver-vol..." docker create --rm --mount source=nvidia-driver-vol,destination=/usr/nvidia gow/nvidia-driver:latest sh >/dev/null log_success "nvidia-driver-vol created" fi # The GOW nvidia-driver image only ships OpenGL/Vulkan libs — not libcuda.so, # libnvcuvid.so, or libnvidia-encode.so, which GStreamer's nvcodec elements # need for NVENC. Copy them straight from the host driver into the volume: # host userspace libs always match the running kernel module, so there's no # version-skew risk; Wolf finds them via LD_LIBRARY_PATH=/usr/nvidia/lib. local _VOL_HAS_CUDA HOST_CUDA HOST_LIB_DIR _VOL_HAS_CUDA=$(docker run --rm -v nvidia-driver-vol:/usr/nvidia alpine \ sh -c 'ls /usr/nvidia/lib/libcuda.so* 2>/dev/null | head -1' 2>/dev/null || echo "") if [ -z "$_VOL_HAS_CUDA" ]; then log_info "Copying CUDA/NVENC libs from host driver into nvidia-driver-vol..." HOST_CUDA=$(ldconfig -p 2>/dev/null | awk '/libcuda\.so\.1/ {print $NF; exit}') [ -z "$HOST_CUDA" ] && HOST_CUDA=$(find /usr/lib /usr/lib64 /usr/lib/x86_64-linux-gnu \ -name 'libcuda.so.*' 2>/dev/null | head -1) if [ -z "$HOST_CUDA" ] || [ ! -e "$HOST_CUDA" ]; then log_warning "Could not find libcuda.so on the host — Wolf may fall back to VA-API." log_warning "Confirm the NVIDIA driver is fully installed (nvidia-smi works)." else HOST_LIB_DIR=$(dirname "$HOST_CUDA") log_info "Host NVIDIA libs: $HOST_LIB_DIR" if docker run --rm \ -v nvidia-driver-vol:/usr/nvidia \ -v "$HOST_LIB_DIR":/hostlib:ro \ alpine sh -c ' mkdir -p /usr/nvidia/lib ok=0 for base in libcuda libnvcuvid libnvidia-encode libnvidia-ptxjitcompiler; do for f in /hostlib/${base}.so*; do [ -e "$f" ] && cp -a "$f" /usr/nvidia/lib/ && ok=1 done done [ -e /usr/nvidia/lib/libcuda.so.1 ] && echo "have-cuda-symlink" [ $ok -eq 1 ] ' 2>&1; then log_success "CUDA/NVENC libs copied — Wolf should now select the NVIDIA encoder" else log_warning "Failed to copy CUDA libs into the volume — Wolf may use VA-API." fi fi else log_success "nvidia-driver-vol already has CUDA libs" fi # ── Game storage location ───────────────────────────────────────────────── # ROMs, Steam library, and saves all live under GAME_STORAGE_DIR. # $GAME_STORAGE_DIR/roms/ → ES-DE at /ROMs # $GAME_STORAGE_DIR/steam/ → Steam at /home/retro/.steam # $GAME_STORAGE_DIR/saves/ → RetroArch saves echo "" echo "═══════════════════════════════════════════════════════" echo " GAME STORAGE LOCATION" echo "═══════════════════════════════════════════════════════" echo "" log_info "Drives on this machine:" lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT | grep -v "^loop" | sed 's/^/ /' echo "" echo " ROMs, Steam library, and saves will be stored under one directory." echo " Recommended: a larger/secondary drive (HDD) to keep the OS SSD free." echo " The directory will be created if it doesn't exist." echo " If it's on an unmounted drive the script will mount it and add it to fstab." echo "" local DEFAULT_STORAGE="$ACTUAL_HOME/drives/games" GAME_STORAGE_DIR="" prompt_text " Game storage path [${DEFAULT_STORAGE}]:" "$DEFAULT_STORAGE" GAME_STORAGE_DIR GAME_STORAGE_DIR="${GAME_STORAGE_DIR:-$DEFAULT_STORAGE}" GAME_STORAGE_DIR="${GAME_STORAGE_DIR/#\~/$ACTUAL_HOME}" # Check if the path crosses an unmounted drive local _PARENT _PARENT=$(dirname "$GAME_STORAGE_DIR") if [ ! -d "$_PARENT" ]; then log_warning "Parent directory $_PARENT does not exist." echo "" echo " If this path is on a separate drive, pick the device to mount:" echo "" lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT | grep -v "^loop" | sed 's/^/ /' echo "" local RAW_DEV="" prompt_text " Device to mount at ${GAME_STORAGE_DIR%/*} (e.g. sda, sdb1) or Enter to skip:" "" RAW_DEV if [ -n "$RAW_DEV" ]; then RAW_DEV="${RAW_DEV##/dev/}" local DEV="/dev/$RAW_DEV" local MOUNT_POINT="${GAME_STORAGE_DIR%/*}" if [ -b "$DEV" ]; then local PARTITION="${DEV}" [[ "$DEV" =~ [0-9]$ ]] || PARTITION="${DEV}1" if ! blkid "$PARTITION" &>/dev/null && \ ! fdisk -l "$DEV" 2>/dev/null | grep -q "^${PARTITION}"; then log_info "Creating partition on $DEV..." printf 'g\nn\n1\n\n\nw\n' | fdisk "$DEV" partprobe "$DEV"; sleep 2 fi if ! blkid -s TYPE "$PARTITION" 2>/dev/null | grep -q TYPE; then log_info "Formatting ${PARTITION} as ext4..." mkfs.ext4 -F -L "games" "$PARTITION" else log_info "${PARTITION} already has a filesystem — keeping data" fi mkdir -p "$MOUNT_POINT" mount "$PARTITION" "$MOUNT_POINT" local PART_UUID PART_UUID=$(blkid -s UUID -o value "$PARTITION") if [ -n "$PART_UUID" ]; then if grep -qs "$PART_UUID" /etc/fstab; then log_info "fstab: UUID=${PART_UUID} already present" else echo "UUID=${PART_UUID} ${MOUNT_POINT} ext4 defaults,nofail 0 2" \ | tee -a /etc/fstab >/dev/null log_success "fstab: UUID=${PART_UUID} → ${MOUNT_POINT} (nofail, auto-mount on boot)" fi else log_warning "Could not read UUID for ${PARTITION} — add /etc/fstab entry manually" fi chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MOUNT_POINT" 2>/dev/null || true log_success "${PARTITION} mounted at ${MOUNT_POINT}" else log_warning "$DEV not found — continuing, ensure drive is mounted before starting Wolf" fi fi fi # Create the storage sub-directories mkdir -p "$GAME_STORAGE_DIR/roms" "$GAME_STORAGE_DIR/steam" \ "$GAME_STORAGE_DIR/saves" "$GAME_STORAGE_DIR/media" log_success "Storage layout: $GAME_STORAGE_DIR/{roms,steam,saves,media}" # ── docker-compose.yml ──────────────────────────────────────────────────── log_info "Generating docker-compose.yml..." mkdir -p /etc/wolf/cfg mkdir -p "$WOLF_DIR" ensure_docker_dir_ownership "$WOLF_DIR" cd "$WOLF_DIR" || return 1 # Detect the LAN interface (the one used to reach the internet, not VPN/loopback) local LAN_IFACE LAN_IP LAN_MAC LAN_IFACE=$(ip route get 8.8.8.8 2>/dev/null | grep -oP 'dev \K\S+' | head -1) LAN_IP=$(ip route get 8.8.8.8 2>/dev/null | grep -oP 'src \K\S+' | head -1) LAN_MAC=$(ip link show "$LAN_IFACE" 2>/dev/null | grep -oP 'ether \K\S+' | head -1) log_success "LAN interface: $LAN_IFACE IP: $LAN_IP MAC: $LAN_MAC" # If Tailscale is running, Wolf must advertise the Tailscale IP so Moonlight # clients on the tailnet can connect. local TS_IP TS_MAC WOLF_IP WOLF_MAC TS_IP=$(tailscale ip -4 2>/dev/null | head -1) if [ -n "$TS_IP" ]; then TS_MAC=$(ip link show tailscale0 2>/dev/null | grep -oP 'ether \K\S+' | head -1) WOLF_IP="$TS_IP" WOLF_MAC="${TS_MAC:-$LAN_MAC}" log_success "Tailscale detected — Wolf will advertise Tailscale IP: $TS_IP" log_info "In Moonlight use 'Add PC' and enter: $TS_IP" else WOLF_IP="$LAN_IP" WOLF_MAC="$LAN_MAC" fi cat > docker-compose.yml << EOF name: wolf services: wolf: image: ghcr.io/games-on-whales/wolf:stable container_name: wolf network_mode: host restart: unless-stopped environment: - NVIDIA_DRIVER_VOLUME_NAME=nvidia-driver-vol - HOST_APPS_STATE_FOLDER=/etc/wolf - WOLF_INTERNAL_IP=${WOLF_IP} - WOLF_INTERNAL_MAC=${WOLF_MAC} - WOLF_RENDER_NODE=${WOLF_RENDER_NODE} - LD_LIBRARY_PATH=/usr/nvidia/lib:/usr/nvidia/lib32 volumes: - /etc/wolf/:/etc/wolf:rw - /var/run/docker.sock:/var/run/docker.sock:rw - /dev/:/dev/:rw - /run/udev:/run/udev:rw - nvidia-driver-vol:/usr/nvidia:rw devices: - /dev/dri - /dev/uinput - /dev/uhid - /dev/nvidia-uvm - /dev/nvidia-uvm-tools - /dev/nvidia-caps/nvidia-cap1 - /dev/nvidia-caps/nvidia-cap2 - /dev/nvidiactl - /dev/nvidia0 - /dev/nvidia-modeset device_cgroup_rules: - 'c 13:* rmw' volumes: nvidia-driver-vol: external: true EOF log_success "docker-compose.yml created" # ── Firewall ────────────────────────────────────────────────────────────── if command -v ufw &>/dev/null; then log_info "Opening Moonlight ports in UFW..." for p in "${WOLF_PORTS_TCP[@]}"; do ufw allow "${p}/tcp" comment "Wolf/Moonlight" >/dev/null 2>&1 || true; done for p in "${WOLF_PORTS_UDP[@]}"; do ufw allow "${p}/udp" comment "Wolf/Moonlight" >/dev/null 2>&1 || true; done log_success "Ports opened: TCP ${WOLF_PORTS_TCP[*]} / UDP ${WOLF_PORTS_UDP[*]}" else log_warning "ufw not installed — if you use a firewall, open these ports:" echo " TCP: ${WOLF_PORTS_TCP[*]} UDP: ${WOLF_PORTS_UDP[*]}" fi # ── Management script ───────────────────────────────────────────────────── cat > manage.sh << 'MEOF' #!/bin/bash case "$1" in start) docker compose up -d; echo "Wolf started. Pair Moonlight to this server's IP." ;; stop) docker compose down ;; restart) docker compose restart ;; logs) docker compose logs -f wolf ;; status) docker compose ps; echo; docker ps --filter "name=Wolf" --format "table {{.Names}}\t{{.Status}}" ;; update) docker compose pull docker compose up -d ;; add-apps) WOLF_CFG=/etc/wolf/cfg/config.toml GAME_DIR="${2}" if [ -z "$GAME_DIR" ]; then echo "Usage: ./manage.sh add-apps /path/to/game/storage" echo " e.g. ./manage.sh add-apps /home/user/drives/games" exit 1 fi if [ ! -f "$WOLF_CFG" ]; then echo "Wolf config not found at $WOLF_CFG — is Wolf running?" exit 1 fi python3 - "$GAME_DIR" "$WOLF_CFG" << 'PYEOF' import sys games = sys.argv[1].rstrip('/') cfg = sys.argv[2] with open(cfg, 'r') as f: lines = f.readlines() profiles_seen, first_start, insert_at = 0, None, len(lines) for i, line in enumerate(lines): if line.strip() == '[[profiles]]': profiles_seen += 1 if profiles_seen == 1: first_start = i elif profiles_seen == 2: insert_at = i; break if first_start is None: print('ERROR: no [[profiles]] section found'); sys.exit(1) first_block = lines[first_start:insert_at] has_steam = any("name = 'WolfSteam'" in l for l in first_block) has_esde = any("name = 'WolfES-DE'" in l for l in first_block) to_insert = [] if not has_steam: to_insert += [ '\n', " [[profiles.apps]]\n", " icon_png_path = 'https://games-on-whales.github.io/wildlife/apps/steam/assets/icon.png'\n", " start_virtual_compositor = true\n", " title = 'Steam'\n", '\n', " [profiles.apps.runner]\n", " base_create_json = '''{\n", ' "HostConfig": {\n', ' "IpcMode": "host",\n', ' "CapAdd": ["SYS_ADMIN", "SYS_NICE", "SYS_PTRACE", "NET_RAW", "MKNOD", "NET_ADMIN"],\n', ' "SecurityOpt": ["seccomp=unconfined", "apparmor=unconfined"],\n', ' "Ulimits": [{"Name":"nofile", "Hard":10240, "Soft":10240}],\n', ' "Privileged": false,\n', ' "DeviceCgroupRules": ["c 13:* rmw", "c 244:* rmw"]\n', ' }\n', "}\n", "'''\n", " devices = []\n", " env = [ 'PROTON_LOG=1', 'RUN_SWAY=true', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*' ]\n", " image = 'ghcr.io/games-on-whales/steam:edge'\n", " mounts = [ '" + games + "/steam:/home/retro/.steam:rw' ]\n", " name = 'WolfSteam'\n", " ports = []\n", " type = 'docker'\n", ] if not has_esde: to_insert += [ '\n', " [[profiles.apps]]\n", " icon_png_path = 'https://games-on-whales.github.io/wildlife/apps/es-de/assets/icon.png'\n", " start_virtual_compositor = true\n", " title = 'EmulationStation'\n", '\n', " [profiles.apps.runner]\n", " base_create_json = '''{\n", ' "HostConfig": {\n', ' "IpcMode": "host",\n', ' "Privileged": false,\n', ' "CapAdd": ["NET_RAW", "MKNOD", "NET_ADMIN"],\n', ' "DeviceCgroupRules": ["c 13:* rmw", "c 244:* rmw"]\n', ' }\n', "}\n", "'''\n", " devices = []\n", " env = [ 'RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*' ]\n", " image = 'ghcr.io/games-on-whales/es-de:edge'\n", " mounts = [ '" + games + "/roms:/ROMs:rw', '" + games + "/saves:/home/retro/.config/retroarch/saves:rw', '" + games + "/media:/media:rw' ]\n", " name = 'WolfES-DE'\n", " ports = []\n", " type = 'docker'\n", ] if to_insert: new_lines = lines[:insert_at] + to_insert + lines[insert_at:] with open(cfg, 'w') as f: f.writelines(new_lines) added = [n for n, exists in [('Steam', has_steam), ('EmulationStation', has_esde)] if not exists] print(f"Added to default profile: {', '.join(added)}") else: print('Both apps already in default profile') PYEOF docker compose restart wolf echo "Wolf restarted. Steam and EmulationStation should now appear in Moonlight." ;; backup) echo "Set up backups with the modular system: sudo ./setup.sh backup" ;; pin) # Wolf logs: "Insert pin at http://SOMEIP:47989/pin/#HEXHASH" # Extract just the hash fragment and build URLs for every interface # so it works whether you're on LAN, VPN, or any other network. HASH=$(docker compose logs wolf 2>&1 | grep "Insert pin at" | tail -1 \ | grep -oP '#[A-Fa-f0-9]+') if [ -z "$HASH" ]; then echo "" echo " No pairing request found." echo " Open Moonlight, add this server by IP, and a PIN URL will appear here." echo "" else # Collect all non-loopback IPv4 addresses ALL_IPS=$(ip -4 addr show | grep -oP '(?<=inet )\d+\.\d+\.\d+\.\d+(?=/)' \ | grep -v '^127\.') echo "" echo " Moonlight is showing a 4-digit PIN." echo " Open ONE of these URLs in a browser and enter that PIN:" echo "" while IFS= read -r ip; do IFACE=$(ip -4 addr show | grep -B2 "inet $ip/" | grep -oP '^\d+: \K\S+(?=:)' | head -1) echo " http://$ip:47989/pin/$HASH ($IFACE)" done <<< "$ALL_IPS" echo "" echo " Use the URL matching whichever network Moonlight is on." echo " (LAN IP for local, VPN/mesh IP for remote)" echo "" fi ;; *) echo "Wolf Cloud Gaming" echo " ./manage.sh start - Start Wolf" echo " ./manage.sh stop - Stop Wolf" echo " ./manage.sh restart - Restart Wolf" echo " ./manage.sh logs - Follow Wolf logs" echo " ./manage.sh status - Show Wolf + app containers" echo " ./manage.sh pin - Show recent Moonlight pairing PIN link" echo " ./manage.sh update - Pull latest Wolf image and restart" echo " ./manage.sh add-apps - Add Steam + ES-DE to Wolf config" echo " ./manage.sh backup - How to set up backups (sudo ./setup.sh backup)" ;; esac MEOF chmod +x manage.sh # ── Start Wolf ──────────────────────────────────────────────────────────── echo "" log_info "Starting Wolf (first start pulls the image — give it a minute)..." docker compose up -d # Wolf writes /etc/wolf/cfg/config.toml on first start. Wait for it, then # wire in game storage. log_info "Waiting for Wolf to generate /etc/wolf/cfg/config.toml..." local WOLF_CFG=/etc/wolf/cfg/config.toml _i for _i in $(seq 1 30); do [ -f "$WOLF_CFG" ] && break sleep 2 done if [ -f "$WOLF_CFG" ]; then log_info "Adding Steam and EmulationStation to Wolf config..." python3 - "$GAME_STORAGE_DIR" "$WOLF_CFG" << 'PYEOF' import sys games = sys.argv[1].rstrip('/') cfg = sys.argv[2] with open(cfg, 'r') as f: lines = f.readlines() # Wolf uses [[profiles]] / [[profiles.apps]] format. # Apps must be inserted into the FIRST profile (the default paired-client # profile) before the second [[profiles]] section starts. profiles_seen, first_start, insert_at = 0, None, len(lines) for i, line in enumerate(lines): if line.strip() == '[[profiles]]': profiles_seen += 1 if profiles_seen == 1: first_start = i elif profiles_seen == 2: insert_at = i; break if first_start is None: print('[ERROR] No [[profiles]] section found in config'); sys.exit(1) first_block = lines[first_start:insert_at] has_steam = any("name = 'WolfSteam'" in l for l in first_block) has_esde = any("name = 'WolfES-DE'" in l for l in first_block) to_insert = [] if not has_steam: to_insert += [ '\n', " [[profiles.apps]]\n", " icon_png_path = 'https://games-on-whales.github.io/wildlife/apps/steam/assets/icon.png'\n", " start_virtual_compositor = true\n", " title = 'Steam'\n", '\n', " [profiles.apps.runner]\n", " base_create_json = '''{\n", ' "HostConfig": {\n', ' "IpcMode": "host",\n', ' "CapAdd": ["SYS_ADMIN", "SYS_NICE", "SYS_PTRACE", "NET_RAW", "MKNOD", "NET_ADMIN"],\n', ' "SecurityOpt": ["seccomp=unconfined", "apparmor=unconfined"],\n', ' "Ulimits": [{"Name":"nofile", "Hard":10240, "Soft":10240}],\n', ' "Privileged": false,\n', ' "DeviceCgroupRules": ["c 13:* rmw", "c 244:* rmw"]\n', ' }\n', "}\n", "'''\n", " devices = []\n", " env = [ 'PROTON_LOG=1', 'RUN_SWAY=true', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*' ]\n", " image = 'ghcr.io/games-on-whales/steam:edge'\n", f" mounts = [ '{games}/steam:/home/retro/.steam:rw' ]\n", " name = 'WolfSteam'\n", " ports = []\n", " type = 'docker'\n", ] if not has_esde: to_insert += [ '\n', " [[profiles.apps]]\n", " icon_png_path = 'https://games-on-whales.github.io/wildlife/apps/es-de/assets/icon.png'\n", " start_virtual_compositor = true\n", " title = 'EmulationStation'\n", '\n', " [profiles.apps.runner]\n", " base_create_json = '''{\n", ' "HostConfig": {\n', ' "IpcMode": "host",\n', ' "Privileged": false,\n', ' "CapAdd": ["NET_RAW", "MKNOD", "NET_ADMIN"],\n', ' "DeviceCgroupRules": ["c 13:* rmw", "c 244:* rmw"]\n', ' }\n', "}\n", "'''\n", " devices = []\n", " env = [ 'RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*' ]\n", " image = 'ghcr.io/games-on-whales/es-de:edge'\n", f" mounts = [ '{games}/roms:/ROMs:rw', '{games}/saves:/home/retro/.config/retroarch/saves:rw', '{games}/media:/media:rw' ]\n", " name = 'WolfES-DE'\n", " ports = []\n", " type = 'docker'\n", ] if to_insert: new_lines = lines[:insert_at] + to_insert + lines[insert_at:] with open(cfg, 'w') as f: f.writelines(new_lines) added = [n for n, exists in [('Steam', has_steam), ('EmulationStation', has_esde)] if not exists] print(f"[INFO] Added to default profile: {', '.join(added)}") else: print('[INFO] Steam and EmulationStation already in default profile') PYEOF docker compose restart wolf log_success "Wolf restarted with updated config" else log_warning "Wolf config not generated in time. Add apps manually to /etc/wolf/cfg/config.toml" log_warning "Then run: ./manage.sh restart" fi # Hand the folder back to the real user chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WOLF_DIR" local ALL_IPS ALL_IPS=$(ip -4 addr show | grep -oP '(?<=inet )\d+\.\d+\.\d+\.\d+(?=/)' | grep -v '^127\.') echo "" echo "═══════════════════════════════════════════════════════" echo " WOLF IS RUNNING" echo "═══════════════════════════════════════════════════════" echo "" echo " Server addresses:" while IFS= read -r ip; do IFACE=$(ip -4 addr show | grep -B2 "inet $ip/" | grep -oP '^\d+: \K\S+(?=:)' | head -1) printf " %-18s (%s)\n" "$ip" "$IFACE" done <<< "$ALL_IPS" echo "" echo "── PAIRING ──────────────────────────────────────────" echo "" echo " 1. Install Moonlight on your device:" echo " • Sony Bravia / Google TV → Play Store ('Moonlight Game Streaming')" echo " • Roku TV → plug in a Fire TV / Chromecast / NVIDIA Shield," echo " install Moonlight there" echo " • Phone / PC / Mac → moonlight-stream.org" echo "" echo " 2. In Moonlight, add this server by IP:" if [ -n "$TS_IP" ]; then echo " Tailscale: $TS_IP ← use this (Wolf is configured for Tailscale)" echo " LAN: $LAN_IP (only works on the local network)" else echo " LAN: $LAN_IP" echo " For remote access install Tailscale, then re-run this module." fi echo "" echo " 3. Moonlight shows a 4-digit PIN. On this server run:" echo " cd $WOLF_DIR && ./manage.sh pin" echo " It prints a URL for every interface — open the one matching" echo " whichever network Moonlight is on, then type the PIN." echo "" echo " 4. First launch of each app downloads its container image." echo " A black screen for ~60 s is normal." echo "" echo " Return to launcher: Ctrl+Alt+Shift+W or START+UP+RB (controller)" echo "" echo "── PAIRING (the ./manage.sh pin workflow) ────────────" echo "" echo " Wolf's PIN entry page is served directly by Wolf on port 47989 — no" echo " separate pairing service or reverse proxy is needed. Workflow:" echo "" echo " 1. Open Moonlight → add server by IP → a 4-digit PIN appears." echo " 2. On this server run: cd $WOLF_DIR && ./manage.sh pin" echo " 3. It extracts the pairing URL from 'docker logs wolf' and prints" echo " one link per interface (LAN, Tailscale, etc.)." echo " 4. Open the link matching Moonlight's network and type the PIN." echo "" echo " NOTE: Moonlight streaming uses direct UDP/TCP to this server's IP" echo " (LAN or VPN). Pairing is just the one-time PIN exchange above." echo "" echo "── GAME STORAGE ──────────────────────────────────────" echo "" echo " ${GAME_STORAGE_DIR}/" echo " roms/ → /ROMs (EmulationStation)" echo " steam/ → /home/retro/.steam (Steam Big Picture)" echo " saves/ → /home/retro/.config/retroarch/saves (RetroArch saves)" echo " media/ → /media (ES-DE scraped artwork)" echo "" echo " Other app data (ES-DE settings, controller mappings, save states," echo " standalone-emulator saves) is persisted by Wolf under /etc/wolf and" echo " is included when you set up backups." echo "" echo "── APPS ──────────────────────────────────────────────" echo "" echo " • EmulationStation - ES-DE + RetroArch + Dolphin/PCSX2/Cemu/Ryujinx/more" echo " • Steam - Big Picture + Proton" echo " • Lutris - Wine / GOG / Epic / non-Steam" echo " • RetroArch - standalone, all cores" echo " • Prismlauncher - Minecraft" echo " • Kodi - media center" echo " • Firefox / Desktop - browser and full XFCE desktop" echo " • Wolf UI / Pegasus - alternative launchers" echo "" echo "── MULTIPLAYER ───────────────────────────────────────" echo "" echo " Same-screen co-op → create a LOBBY in Wolf UI; each joiner gets" echo " their own virtual gamepad (1 stream)" echo " Online together → each player launches their own session," echo " all connect to the same game server" echo "" echo "Manage: cd $WOLF_DIR && ./manage.sh {start|stop|restart|logs|status|pin|update|add-apps}" echo "" echo "── BACKUPS ───────────────────────────────────────────" echo "" echo " Back up your saves, progress and user data (Steam user data and all of" echo " /etc/wolf: ES-DE settings, controller mappings, RetroArch saves/states," echo " emulator saves). ROMs and game installs are skipped." echo "" echo " Set up automatic backups with the backup module:" echo " sudo ./setup.sh backup" echo "" # ── Caddy reverse proxy (Wolf web UI — no built-in auth, offer Authelia) ──── local WOLF_EXTRA_BLOCK="" if [ -d "$DOCKER_DIR/authelia" ]; then local _use_auth="" prompt_yn "Protect Wolf web UI with Authelia SSO? (y/n):" "y" _use_auth [[ "$_use_auth" =~ ^[Yy]$ ]] && WOLF_EXTRA_BLOCK=" import authelia" fi configure_caddy_for_service "Wolf" "wolf:47990" "wolf" "$WOLF_EXTRA_BLOCK" write_readme "$WOLF_DIR" << MD # Wolf — Cloud Gaming Cloud gaming via Moonlight. Stream any Moonlight-compatible game or app from this server to any device on your network. ## Manage \`\`\`bash cd $WOLF_DIR ./manage.sh start # start Wolf ./manage.sh stop # stop Wolf ./manage.sh logs # stream logs ./manage.sh pin # show pairing PIN ./manage.sh update # pull latest and restart \`\`\` ## Pairing 1. Open Moonlight on your device, select this server 2. Run \`./manage.sh pin\` to get the pairing PIN 3. Enter the PIN in Moonlight ## Game storage: \`$GAME_STORAGE_DIR\` - \`roms/\` → /ROMs (EmulationStation) - \`steam/\` → Steam data - \`saves/\` → RetroArch saves MD local START_WOLF="" prompt_yn "Start Wolf now? (y/n):" "y" START_WOLF if [ "$START_WOLF" = "y" ] || [ "$START_WOLF" = "Y" ]; then docker compose up -d && log_success "Wolf started" || log_warning "Failed to start — check: docker compose logs" fi log_success "Done. Pair Moonlight and play." } [[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_wolf