Files
ubuntu-post-install/services/wolf.sh
T
Claude c57f760fdc manage.sh controllers: collapse duplicate-paired devices to one entry
Live user feedback: being asked to pick between 6 numbered entries that
were all the exact same device (client_id repeated 6x from re-pairing)
was genuinely confusing, especially right when the user was already
trying to get to the controller-type poll further down the flow.

The picker now dedupes to distinct client_ids only, first-occurrence
order, tagging a collapsed entry "(paired Nx)" - matches what Wolf's own
get_client_by_id() actually resolves to anyway (first match for a given
id), so nothing is lost by not offering the later duplicates as separate
choices. Verified against the user's real 8-entry (6 duplicate + 2
unique) client list, both with and without an active session, confirming
the controller log-poll still runs correctly right after selection in
both cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 18:03:45 +00:00

4861 lines
251 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"
}
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
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
# Downloads the latest GitHub-released AppImage for a standalone emulator into
# $EMU_DIR (skipped if a matching file is already there). AppImages dropped
# there are found automatically by ES-DE's app finder (it checks
# ~/Applications inside the container, mounted from emulators/) — this covers
# every system ES-DE hands off to a standalone frontend instead of a
# RetroArch libretro core (3DS, GameCube/Wii, PS2, ...). Same shape as the
# original Azahar-only download this was factored out of, just parameterized
# so PCSX2/Dolphin/anything else reuse the same fetch-latest-release logic
# instead of copy-pasting it per emulator.
_wolf_download_emulator_appimage() {
local _display_name="$1" _owner_repo="$2" _existing_glob="$3" _dir="$4"
if ls "$_dir"/$_existing_glob 2>/dev/null | grep -q .; then
log_info "$_display_name already present in $_dir/"
return 0
fi
local _get=""
echo ""
log_info "$_display_name can be auto-downloaded."
prompt_yn "Download $_display_name AppImage now? (y/n):" "y" _get
[[ "$_get" =~ ^[Yy]$ ]] || return 0
log_info "Fetching latest $_display_name release from GitHub..."
# A release can publish AppImages for more than one CPU architecture
# (x86_64 and aarch64 both showing up as plain "*.AppImage" assets) with
# no guarantee the one this host needs sorts first in the GitHub API's
# asset list. Prefer whichever asset's filename actually tags the host's
# own architecture; fall back to an asset with no arch tag at all before
# ever falling back to "just take the first one".
local _url
_url=$(curl -fsSL "https://api.github.com/repos/${_owner_repo}/releases/latest" \
| HOST_ARCH="$(uname -m)" python3 -c '
import sys, json, os
host = os.environ.get("HOST_ARCH", "")
arch_tags = {
"x86_64": ["x86_64", "amd64", "x64"],
"aarch64": ["aarch64", "arm64"],
"arm64": ["aarch64", "arm64"],
}.get(host, [host] if host else [])
all_arch_tags = ["x86_64", "amd64", "x64", "aarch64", "arm64", "armv7", "armhf", "i386", "i686"]
def has(name, tags):
n = name.lower()
return any(t in n for t in tags)
r = json.load(sys.stdin)
assets = [a for a in r["assets"] if a["name"].endswith(".AppImage")]
matching = [a for a in assets if arch_tags and has(a["name"], arch_tags)]
untagged = [a for a in assets if not has(a["name"], all_arch_tags)]
pick = matching or untagged or assets
print(pick[0]["browser_download_url"] if pick else "")
' 2>/dev/null)
if [[ -z "$_url" ]]; then
log_warning "Could not resolve download URL — get it manually from https://github.com/${_owner_repo}/releases"
return 1
fi
local _file="$_dir/$(basename "$_url")"
curl -fL --progress-bar -o "$_file" "$_url" \
&& chmod +x "$_file" \
&& chown "$ACTUAL_USER:$ACTUAL_USER" "$_file" \
&& log_success "$_display_name downloaded: $_file" \
|| { log_warning "Download failed — get it manually from https://github.com/${_owner_repo}/releases"; return 1; }
# Belt-and-suspenders: the filename-based preference above can't help
# when a release tags no architecture in the name at all, so verify the
# actual ELF header matches this host post-download. Confirmed live: a
# wrong-arch AppImage downloads with no error and no visible symptom
# until launch time, where it fails as a bare "exec format error" with
# nothing pointing back at the cause.
local _got_arch
_got_arch=$(file -b "$_file" 2>/dev/null)
case "$(uname -m)" in
x86_64)
echo "$_got_arch" | grep -qi 'x86-64\|x86_64' || \
log_warning "$_file doesn't look like an x86_64 build ($_got_arch) — it will fail with 'exec format error'. Grab the x86_64 asset by hand from https://github.com/${_owner_repo}/releases"
;;
aarch64|arm64)
echo "$_got_arch" | grep -qi 'aarch64\|arm64' || \
log_warning "$_file doesn't look like an aarch64 build ($_got_arch) — it may fail to run. Grab the aarch64 asset by hand from https://github.com/${_owner_repo}/releases"
;;
esac
}
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"
echo " - Create bios/ + retroarch/{cores,shaders,overlays}/ on the game drive, pre-download RetroArch"
echo " cores (~1.5 GB), and fetch Dolphin's Sys folder so GC/Wii boot clean on first run"
echo " - Offer to auto-download standalone emulator AppImages into emulators/:"
echo " Azahar (3DS), PCSX2 (PS2), Dolphin (GameCube/Wii — unofficial community build,"
echo " symlinked to Dolphin_Emulator.AppImage so ES-DE's own find-rules can see it)"
echo " - Offer AntiMicroX (gamepad -> keyboard/mouse remapping), scoped to just TI-99/4A"
echo " and Wii U (Cemu) in ES-DE via a second, opt-in 'Alternative emulators' command"
echo " - Expose Wolf's REST API socket to the host (WOLF_SOCKET_PATH + /var/run/wolf mount)"
echo " so './manage.sh controllers' can force distinct pad types per controller slot"
echo " - Force ES-DE's 'Run in background' off durably (mounts + writes esde-settings/es_settings.xml)"
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 ""
echo " ROMs, Steam library, and saves all live under one directory."
echo " Recommended: a large secondary drive so the OS SSD stays free."
echo ""
# ── Build a numbered candidate list ──────────────────────────────────────
# Slots: 0 = home dir, 1..N = mounted non-system partitions,
# last = unmounted block devices (for formatting+mounting)
local -a _CAND_PATH _CAND_LABEL _CAND_DEV _CAND_UUID
local _ci=0
# Option 0 — home directory
local _home_free _home_dev _home_uuid
_home_free=$(df -h "$ACTUAL_HOME" 2>/dev/null | awk 'NR==2{print $4}')
_home_dev=$(df "$ACTUAL_HOME" 2>/dev/null | awk 'NR==2{print $1}')
_home_uuid=$(blkid -s UUID -o value "$_home_dev" 2>/dev/null)
_CAND_PATH[0]="$ACTUAL_HOME/games"
_CAND_LABEL[0]="Home directory ($ACTUAL_HOME, ${_home_free:-?} free)"
_CAND_DEV[0]="$_home_dev"
_CAND_UUID[0]="${_home_uuid:-n/a}"
_ci=1
# Mounted block devices — skip root, system paths, and home itself.
# Enumerate with lsblk -P (key="value" pairs) so whole-disk mounts (e.g. an
# nvme formatted directly with no partition table) and empty LABEL/UUID
# fields are handled reliably — df round-trips miss some of these.
while IFS= read -r _line; do
local NAME="" MOUNTPOINT="" SIZE="" LABEL="" UUID=""
eval "$_line"
local _mnt="$MOUNTPOINT"
[[ -z "$_mnt" ]] && continue
[[ "$_mnt" == "/" ]] && continue
[[ "$_mnt" == /boot* ]] && continue
[[ "$_mnt" == /snap* ]] && continue
[[ "$_mnt" == /tmp* ]] && continue
[[ "$_mnt" == /run* ]] && continue
[[ "$_mnt" == /sys* ]] && continue
[[ "$_mnt" == /proc* ]] && continue
[[ "$_mnt" == /dev* ]] && continue
[[ "$_mnt" == "[SWAP]" ]] && continue
[[ "$_mnt" == "$ACTUAL_HOME" ]] && continue
local _free
_free=$(df -h "$_mnt" 2>/dev/null | awk 'NR==2{print $4}')
local _display="${LABEL:-$NAME}"
_CAND_PATH[$_ci]="$_mnt/games"
_CAND_LABEL[$_ci]="$_mnt (${_display}, ${SIZE:-?} total, ${_free:-?} free)"
_CAND_DEV[$_ci]="/dev/$NAME"
_CAND_UUID[$_ci]="${UUID:-n/a}"
((_ci++))
done < <(lsblk -Pno NAME,MOUNTPOINT,SIZE,LABEL,UUID 2>/dev/null)
# Unmounted block devices — skip disks that have any mounted partition.
# Explicit =() initializers, not bare `local -a` — under setup.sh's
# `set -u`, an array that never gets an element assigned (e.g. a laptop
# with no second/unmounted drive at all) can still trip "unbound
# variable" on later reads like ${#_UNMT_DEV[@]} below, even though it
# was declared with `local -a`. Confirmed live on a single-drive laptop.
local -a _UNMT_DEV=() _UNMT_LABEL=() _UNMT_UUID=()
local _ui=0
while IFS= read -r _line; do
local _name _size _type _fstype _mnt _label
read -r _name _size _type _fstype _mnt _label <<< "$_line"
[[ "$_name" =~ ^loop ]] && continue
[[ "$_type" != "disk" && "$_type" != "part" ]] && continue
[[ -n "$_mnt" ]] && continue # this entry itself is mounted
# For whole disks, also skip if any child partition is mounted
if [[ "$_type" == "disk" ]]; then
lsblk -no MOUNTPOINT "/dev/$_name" 2>/dev/null | grep -q '[^[:space:]]' && continue
fi
local _uuid
_uuid=$(blkid -s UUID -o value "/dev/$_name" 2>/dev/null)
_UNMT_DEV[$_ui]="$_name"
_UNMT_LABEL[$_ui]="${_label:+$_label, }${_size}${_fstype:+ ($_fstype)}"
_UNMT_UUID[$_ui]="${_uuid:-no UUID}"
((_ui++))
done < <(lsblk -no NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL 2>/dev/null)
# ── Display the menu ──────────────────────────────────────────────────────
echo " Mounted locations:"
local _n
for _n in "${!_CAND_PATH[@]}"; do
printf " %2d) %s\n" "$((_n + 1))" "${_CAND_LABEL[$_n]}"
printf " UUID: %s\n" "${_CAND_UUID[$_n]}"
printf " → %s\n" "${_CAND_PATH[$_n]}"
done
if [ "${#_UNMT_DEV[@]}" -gt 0 ]; then
echo ""
echo " Unmounted drives (script can format + mount):"
for _n in "${!_UNMT_DEV[@]}"; do
printf " U%d) /dev/%s %s\n" "$((_n + 1))" "${_UNMT_DEV[$_n]}" "${_UNMT_LABEL[$_n]}"
printf " UUID: %s\n" "${_UNMT_UUID[$_n]}"
done
fi
echo ""
echo " c) Enter a custom path"
echo ""
local _PICK="" _BASE_PATH="" GAME_STORAGE_DIR=""
if [ "$UNATTENDED" = true ]; then
_BASE_PATH="${_CAND_PATH[0]}"
log_info "Unattended — using default: $_BASE_PATH"
else
while true; do
read -r -p " Select drive [1]: " _PICK
_PICK="${_PICK:-1}"
if [[ "$_PICK" =~ ^[0-9]+$ ]] && [ "$_PICK" -ge 1 ] && [ "$_PICK" -le "${#_CAND_PATH[@]}" ]; then
_BASE_PATH="${_CAND_PATH[$((_PICK - 1))]}"
break
elif [[ "${_PICK,,}" =~ ^u([0-9]+)$ ]]; then
local _uidx=$(( ${BASH_REMATCH[1]} - 1 ))
if [ "$_uidx" -ge 0 ] && [ "$_uidx" -lt "${#_UNMT_DEV[@]}" ]; then
_BASE_PATH="" # will be set after mounting below
break
fi
echo " Invalid selection — try again."
elif [[ "${_PICK,,}" == "c" ]]; then
_BASE_PATH=""
break
else
echo " Invalid selection — enter a number, U<n>, or c."
fi
done
fi
# ── Handle unmounted drive selection ─────────────────────────────────────
if [[ "${_PICK,,}" =~ ^u([0-9]+)$ ]]; then
local _uidx=$(( ${BASH_REMATCH[1]} - 1 ))
local _RAW_DEV="${_UNMT_DEV[$_uidx]}"
local _DEV="/dev/$_RAW_DEV"
local _DEFAULT_MP="$ACTUAL_HOME/drives/${_RAW_DEV%%[0-9]}"
local _MOUNT_POINT=""
prompt_text " Mount point for /dev/$_RAW_DEV [${_DEFAULT_MP}]:" "$_DEFAULT_MP" _MOUNT_POINT
_MOUNT_POINT="${_MOUNT_POINT:-$_DEFAULT_MP}"
local _PARTITION="$_DEV"
[[ "$_DEV" =~ [0-9]$ ]] || _PARTITION="${_DEV}1"
if ! blkid "$_PARTITION" &>/dev/null; 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 existing 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}"
fi
else
log_warning "Could not read UUID — 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"
_BASE_PATH="$_MOUNT_POINT/games"
fi
# ── Handle custom path ────────────────────────────────────────────────────
if [[ "${_PICK,,}" == "c" ]]; then
local _CUSTOM=""
prompt_text " Full game storage path:" "$ACTUAL_HOME/games" _CUSTOM
_BASE_PATH="${_CUSTOM:-$ACTUAL_HOME/games}"
_BASE_PATH="${_BASE_PATH/#\~/$ACTUAL_HOME}"
fi
# ── Let user confirm / edit the subdirectory ──────────────────────────────
# _BASE_PATH is now the full suggested path (e.g. /mnt/bigdrive/games).
# Show it and let the user change the trailing component.
echo ""
log_info "Suggested game storage path: $_BASE_PATH"
local _FINAL=""
prompt_text " Confirm or edit path [${_BASE_PATH}]:" "$_BASE_PATH" _FINAL
GAME_STORAGE_DIR="${_FINAL:-$_BASE_PATH}"
GAME_STORAGE_DIR="${GAME_STORAGE_DIR/#\~/$ACTUAL_HOME}"
log_success "Game storage: $GAME_STORAGE_DIR"
# Create the storage sub-directories
mkdir -p "$GAME_STORAGE_DIR/saves" \
"$GAME_STORAGE_DIR/media" "$GAME_STORAGE_DIR/lutris" \
"$GAME_STORAGE_DIR/firefox" "$GAME_STORAGE_DIR/minecraft" \
"$GAME_STORAGE_DIR/kodi" "$GAME_STORAGE_DIR/emulators" \
"$GAME_STORAGE_DIR/steam-cache" \
"$GAME_STORAGE_DIR/bios" "$GAME_STORAGE_DIR/retroarch/cores" \
"$GAME_STORAGE_DIR/retroarch/shaders" "$GAME_STORAGE_DIR/retroarch/overlays" \
"$GAME_STORAGE_DIR/retro-home" "$GAME_STORAGE_DIR/retro-home-data" \
"$GAME_STORAGE_DIR/esde-custom-systems"
# ── Wolf state folder on the game drive ───────────────────────────────────
# This is the clean way to keep Steam (and everything else) off the OS
# drive: relocate Wolf's ENTIRE state folder onto the game drive instead of
# symlinking individual app homes. Wolf stores each app's session home,
# Steam install, downloaded games, and Proton prefixes under this folder,
# and writes its own config.toml under <state>/cfg/. We mount it at the
# SAME path inside and outside the wolf container so that the app containers
# Wolf spawns through the Docker socket (which receive HOST paths) resolve
# correctly on the host. No symlinks, no libraryfolders.vdf, no fix-perms.
local WOLF_STATE_DIR="$GAME_STORAGE_DIR/wolf-state"
local WOLF_CFG="$WOLF_STATE_DIR/cfg/config.toml"
mkdir -p "$WOLF_STATE_DIR/cfg"
chown -R 1000:1000 "$WOLF_STATE_DIR" 2>/dev/null || true
# Migrate any pre-existing Wolf state from the default OS-drive location so
# an upgrading user keeps their Steam install / games instead of losing them.
if [ -d /etc/wolf ] && [ -n "$(ls -A /etc/wolf 2>/dev/null)" ] \
&& [ -z "$(ls -A "$WOLF_STATE_DIR" 2>/dev/null | grep -v '^cfg$')" ]; then
local _MIGRATE=""
prompt_yn "Existing Wolf data found at /etc/wolf (OS drive). Move it to $WOLF_STATE_DIR now? (y/n):" "y" _MIGRATE
if [[ "$_MIGRATE" =~ ^[Yy]$ ]]; then
log_info "Migrating /etc/wolf → $WOLF_STATE_DIR (this can take a while for large Steam installs)..."
cp -a /etc/wolf/. "$WOLF_STATE_DIR"/ \
&& rm -rf /etc/wolf/* \
&& log_success "Migration complete — old data now on the game drive" \
|| log_warning "Migration hit an error; check $WOLF_STATE_DIR before deleting /etc/wolf"
chown -R 1000:1000 "$WOLF_STATE_DIR" 2>/dev/null || true
fi
fi
# Pre-create ES-DE ROM directories so the user knows where to drop files
# and ES-DE shows the system in its list immediately on first launch.
local _ESDE_SYSTEMS=(
3do amstradcpc arcade atari2600 atari5200 atari7800 atari800
atarijaguar atarilynx atarist c64 cavestory channelf coco colecovision
dreamcast dos famicom fds gamegear gb gba gbc gc genesis
gx4000 intellivision j2me lynx mame megadrive megadrive-japan
msx msx2 n64 naomi nds neogeo neogeocd ngp ngpc nes nintendo3ds
odyssey2 pc88 pc98 pcengine pcenginecd pcfx pokemini ps2 ps3 psp psx
saturn scummvm sega32x segacd sg-1000 snes snesmsu1 supervision
switch tg16 tg-cd vectrex vic20 videopac wii wiiu wonderswan
wonderswancolor x68000 xbox xbox360 zmachine zxspectrum
)
local _sys
for _sys in "${_ESDE_SYSTEMS[@]}"; do
mkdir -p "$GAME_STORAGE_DIR/roms/$_sys"
done
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$GAME_STORAGE_DIR" 2>/dev/null || true
# The GoW app containers' 'retro' user is hardcoded to uid 1000 regardless
# of what ACTUAL_USER's own uid happens to be — force these directly onto
# 1000:1000 so they're writable inside the container even when
# ACTUAL_USER isn't uid 1000. retro-home/retro-home-data back
# /home/retro/.config and /home/retro/.local/share respectively (see the
# esde/retroarch CATALOG mounts) — apps split state across both XDG dirs
# (Waybar/Sway/RetroArch's own config lives under .config; Dolphin's
# emulated Wii NAND in particular lives under .local/share/dolphin-emu —
# confirmed live: "data is corrupted" / "could not write to/read from Wii
# system memory" was this directory not existing at all, since only
# .config had a persistent mount before this).
chown -R 1000:1000 "$GAME_STORAGE_DIR/retro-home" "$GAME_STORAGE_DIR/retro-home-data" "$GAME_STORAGE_DIR/retroarch" "$GAME_STORAGE_DIR/esde-custom-systems" 2>/dev/null || true
log_success "Storage layout: $GAME_STORAGE_DIR/{roms/<system>/,steam,saves,media,emulators,...}"
log_info "ES-DE ROM directories pre-created. Drop ROMs in the matching subfolder."
# ── Optional: download standalone emulator AppImages ─────────────────────
# AppImages dropped in emulators/ are found automatically by ES-DE's app
# finder (checks ~/Applications inside the container). These are the
# systems ES-DE hands off to a standalone frontend rather than a
# RetroArch libretro core (Dolphin's libretro core in particular is
# unstable for Wii — confirmed live: "could not write to/read from Wii
# system memory" errors go away once a real standalone Dolphin build is
# present instead).
local _EMU_DIR="$GAME_STORAGE_DIR/emulators"
_wolf_download_emulator_appimage \
"Azahar (3DS — open-source Citra fork)" "azahar-emu/azahar" "azahar*.AppImage" "$_EMU_DIR"
_wolf_download_emulator_appimage \
"PCSX2 (PS2)" "PCSX2/pcsx2" "pcsx2*.AppImage" "$_EMU_DIR"
# Dolphin itself ships no official Linux AppImage — dolphin-emu.org's own
# Linux distribution is Flatpak-only. pkgforge-dev/Dolphin-emu-AppImage is
# a well-regarded THIRD-PARTY community build, not an official Dolphin
# release — flagged explicitly rather than silently offered as if it were
# one, so skip it and grab Dolphin's own Flatpak/build by hand instead if
# you'd rather not run an unofficial build.
if ! ls "$_EMU_DIR"/*[Dd]olphin*.AppImage 2>/dev/null | grep -q .; then
log_warning "Dolphin (GameCube/Wii) has no official Linux AppImage (dolphin-emu.org ships Flatpak only)."
log_warning "The option below is a well-regarded but THIRD-PARTY community build (pkgforge-dev), not"
log_warning "an official Dolphin release."
fi
_wolf_download_emulator_appimage \
"Dolphin (GameCube/Wii, community AppImage build)" "pkgforge-dev/Dolphin-emu-AppImage" "*[Dd]olphin*.AppImage" "$_EMU_DIR"
# ES-DE's own find-rules (es_find_rules.xml, DOLPHIN entry's <staticpath>)
# only match a file literally named Dolphin_Emulator*.AppImage under
# ~/Applications — confirmed live: ES-DE reported "Couldn't launch game,
# emulator not found" / unresolved %EMULATOR_DOLPHIN% even with the
# AppImage sitting right there in emulators/, because pkgforge-dev's own
# release asset isn't named that. Point ES-DE at it with a same-directory
# symlink under the exact name it looks for, without renaming/losing the
# real vendor filename. Re-run unconditionally (not just right after a
# fresh download) so this also repairs an existing install that grabbed
# the file before this fix existed.
local _DOLPHIN_REAL
_DOLPHIN_REAL=$(ls "$_EMU_DIR"/*[Dd]olphin*.AppImage 2>/dev/null | grep -vi '/Dolphin_Emulator.*\.AppImage$' | head -1)
if [[ -n "$_DOLPHIN_REAL" ]]; then
ln -sf "$(basename "$_DOLPHIN_REAL")" "$_EMU_DIR/Dolphin_Emulator.AppImage"
chown -h "$ACTUAL_USER:$ACTUAL_USER" "$_EMU_DIR/Dolphin_Emulator.AppImage" 2>/dev/null || true
log_success "Linked $_EMU_DIR/Dolphin_Emulator.AppImage -> $(basename "$_DOLPHIN_REAL") (the filename ES-DE's own find-rules require)"
fi
# Cemu (Wii U) — no libretro core exists (checked live: a third-party
# attempt exists but was never merged into RetroArch and there are no
# plans to; standalone is the only real path). Cemu's own official
# release asset is already named Cemu-<version>-x86_64.AppImage, which
# matches ES-DE's own find-rule (Cemu*.AppImage) directly — no
# Dolphin-style rename/symlink needed here.
#
# SECURITY NOTE: Cemu's official Linux release assets were compromised
# (supply-chain attack) for a period around v2.6 in 2026-05, later
# restored — confirmed via Datadog Security Labs' public writeup. Always
# download from cemu-project/Cemu's own GitHub releases (which this
# does) rather than a mirror, and check the project's own release notes
# if you want to verify checksums before running it.
log_warning "Cemu (Wii U) downloads from cemu-project's official releases. Its Linux release assets"
log_warning "were briefly compromised in a supply-chain attack around v2.6 (since restored) — worth"
log_warning "knowing given Wii U games are otherwise unverifiable executables running with your GPU."
_wolf_download_emulator_appimage \
"Cemu (Wii U)" "cemu-project/Cemu" "Cemu*.AppImage" "$_EMU_DIR"
log_info "Cemu needs its own Wii U common key (keys.txt) for most encrypted retail games — that's"
log_info "not something this installer can supply. Cemu's own First-Time Setup Wizard covers where"
log_info "to put it once you have one."
# ── Optional: TI-99/4A as its own ES-DE system ────────────────────────────
# TI-99/4A has no libretro core and isn't one of ES-DE's built-in systems,
# so getting it real ES-DE treatment (artwork scraping, gameplay-time
# tracking) needs a custom system definition — confirmed against ES-DE's
# own USERGUIDE.md: a custom_systems/es_systems.xml, living outside the
# bundled config specifically so it complements rather than replaces it,
# at ~/ES-DE/custom_systems/ inside the container (its own mount, added
# above — that path isn't covered by any of the existing .config/
# .local/share mounts). js99er (this repo's existing browser-based
# TI-99/4A emulator, a separate non-Wolf service) is deliberately not
# used here — it's not something ES-DE can launch as a system.
#
# ti99sim-sdl is the real target — genuine SDL2 joystick/gamepad
# support (v0.16.0 from the original author, mrousseau.org — the same
# source RetroPie's own ti99sim.sh scriptmodule builds from; an older
# GitHub fork under a different account turned out to still target
# SDL 1.2 and was dropped after failing to compile against SDL2) — but
# it's source-only with no AppImage, so unlike Azahar/PCSX2/Dolphin/Cemu this
# installer can't just download a working binary. Building it against the
# HOST's glibc/SDL2 risks a mismatch against the ES-DE container's own
# (different) runtime, so instead it's built INSIDE a throwaway container
# running the exact same image ES-DE itself runs
# (ghcr.io/games-on-whales/es-de:edge) — that's what actually guarantees
# compatibility rather than just avoiding the question. The custom system
# definition below always gets written — cheap and correct regardless —
# pointing at emulators/ti99sim-sdl (the same ~/Applications convention
# every other standalone emulator here uses).
echo ""
local _GET_TI99=""
prompt_yn "Set up TI-99/4A as its own ES-DE system (artwork scraping, gameplay-time tracking)? (y/n):" "n" _GET_TI99
if [[ "$_GET_TI99" =~ ^[Yy]$ ]]; then
mkdir -p "$GAME_STORAGE_DIR/esde-custom-systems" "$GAME_STORAGE_DIR/roms/ti994a"
local _TI99_XML="$GAME_STORAGE_DIR/esde-custom-systems/es_systems.xml"
backup_if_exists "$_TI99_XML"
# Always strip and re-add the ti994a block rather than "skip if
# already present" — confirmed live: that check left a stale
# <command> line in place across a real fix to it, since existing
# was already "present" and the check never looked at whether its
# content matched the current template.
#
# The <command> below does NOT use a literal "/bin/bash -c ..."
# string — confirmed against ES-DE's own source (FileData::findEmulator())
# and every real %INJECT%=...esprefix example in ES-DE's own shipped
# es_systems.xml (Dolphin/PrimeHack/Triforce/Supermodel): findEmulator()
# always runs against the <command> string's emulator token to decide
# "found" vs "not found", and %INJECT%=file is only ever paired with
# an %EMULATOR_X%/%CORE_X% placeholder in every real example — never
# with a literal path. Confirmed live: a literal "/bin/bash -c ..."
# after %INJECT%=%BASENAME%.esprefix made ES-DE log "Couldn't launch
# game, emulator not found" even though /bin/bash obviously exists —
# findEmulator() isn't falling back to a plain existence check on the
# next token there, it requires the %EMULATOR_X% resolution path.
# Fixed by giving ti99sim-sdl a real es_find_rules.xml entry (written
# below) and using %EMULATOR_TI99SIM%, matching the pattern every
# built-in standalone emulator uses. %STARTDIR%=%EMUDIR% (ES-DE's own
# "directory containing the resolved emulator binary" variable)
# replaces the old "cd /home/retro/Applications &&" shell prefix —
# ti99sim-sdl still needs its cwd to be its own install dir to find
# the console ROM (TI-994A.ctg) via a plain relative lookup, same as
# RetroPie's own ti99sim.sh does with its "pushd $md_inst" launch.
# The strip regex's trailing whitespace-eater is [ \t]*\n? (one
# line's trailing spaces + one newline), not \s* — confirmed live
# (via a standalone test harness) that a bare \s* reaches PAST this
# block's own trailing newline into the NEXT <system>'s leading
# indentation once a second custom system exists in the same file
# (e.g. the wiiu block the AntiMicroX setup step adds below), and on
# every rerun the two blocks trade indentation back and forth —
# cosmetic only (still well-formed XML, ES-DE doesn't care), but
# needlessly messy across reruns. Every other strip-and-reappend
# block in this file (TI99SIM/TI99SIM_AM rules, wiiu system,
# CEMU_AM rule) uses the same fixed pattern for the same reason.
python3 - "$_TI99_XML" << 'TI99XMLPY'
import re, sys
path = sys.argv[1]
try:
with open(path) as f:
content = f.read()
except FileNotFoundError:
content = '<systemList>\n</systemList>\n'
content = re.sub(r'[ \t]*<system>\s*<name>ti994a</name>.*?</system>[ \t]*\n?', '', content, flags=re.DOTALL)
block = ''' <system>
<name>ti994a</name>
<fullname>Texas Instruments TI-99/4A</fullname>
<path>%ROMPATH%/ti994a</path>
<extension>.ctg .rpk .bin</extension>
<command label="TI99SIM (Standalone)">%INJECT%=%BASENAME%.esprefix %STARTDIR%=%EMUDIR% %EMULATOR_TI99SIM% --joystick1=1 --fullscreen %ROM%</command>
<command label="TI99SIM (AntiMicroX)">%INJECT%=%BASENAME%.esprefix %STARTDIR%=%EMUDIR% %EMULATOR_TI99SIM_AM% --joystick1=1 --fullscreen %ROM%</command>
<platform>ti99</platform>
<theme>ti99</theme>
</system>
'''
content = content.replace('</systemList>', block + '</systemList>')
with open(path, 'w') as f:
f.write(content)
TI99XMLPY
chown "$ACTUAL_USER:$ACTUAL_USER" "$_TI99_XML"
log_success "TI-99/4A ES-DE system definition written/refreshed (roms/ti994a/, custom_systems/es_systems.xml)"
# es_find_rules.xml lives alongside custom es_systems.xml in the same
# custom_systems/ directory (confirmed against ES-DE's own
# USERGUIDE.md — "customize the find rules via the es_find_rules.xml
# file", same complement-not-replace logic as custom es_systems.xml).
# This is what makes %EMULATOR_TI99SIM% above resolvable at all —
# without it, ES-DE has no rule telling it what "TI99SIM" even means
# and would report the emulator as not found regardless of anything
# in es_systems.xml. Same strip-and-reappend idempotency as above, in
# case this file ever gains other custom emulator entries later.
local _TI99_RULES="$GAME_STORAGE_DIR/esde-custom-systems/es_find_rules.xml"
backup_if_exists "$_TI99_RULES"
python3 - "$_TI99_RULES" << 'TI99RULESPY'
import re, sys
path = sys.argv[1]
try:
with open(path) as f:
content = f.read()
except FileNotFoundError:
content = '<ruleList>\n</ruleList>\n'
content = re.sub(r'[ \t]*<emulator name="TI99SIM">.*?</emulator>[ \t]*\n?', '', content, flags=re.DOTALL)
content = re.sub(r'[ \t]*<emulator name="TI99SIM_AM">.*?</emulator>[ \t]*\n?', '', content, flags=re.DOTALL)
block = ''' <emulator name="TI99SIM">
<rule type="staticpath">
<entry>~/Applications/ti99sim-sdl</entry>
</rule>
</emulator>
<emulator name="TI99SIM_AM">
<rule type="staticpath">
<entry>~/Applications/ti99sim-sdl-antimicrox</entry>
</rule>
</emulator>
'''
content = content.replace('</ruleList>', block + '</ruleList>')
with open(path, 'w') as f:
f.write(content)
TI99RULESPY
chown "$ACTUAL_USER:$ACTUAL_USER" "$_TI99_RULES"
log_success "TI-99/4A ES-DE find rule written/refreshed (custom_systems/es_find_rules.xml)"
echo ""
if [ ! -x "$GAME_STORAGE_DIR/emulators/ti99sim-sdl" ]; then
# Build inside a container running the EXACT SAME image ES-DE
# itself runs (ghcr.io/games-on-whales/es-de:edge), not the host —
# that's what actually eliminates the glibc/SDL2 mismatch risk,
# rather than just noting it and asking the user to build blind.
# --entrypoint overrides GoW's own init (which normally drops to
# the unprivileged 'retro' user), so -u root is explicit here for
# apt-get; the built binary only ever runs later as 'retro',
# unaffected by what user built it.
log_info "Building ti99sim-sdl inside the same container image ES-DE runs (matches its"
log_info "glibc/SDL2 exactly, so the result is guaranteed compatible)..."
# `make` alone leaves the binary under src/, not the repo root —
# confirmed against the project's own README ("all the
# executables are left in their corresponding directories").
# `make install` is the project's own reliable way to collect
# it (copies to /opt/ti99sim/bin, symlinks into
# /usr/local/bin), so use that instead of guessing the exact
# build subpath.
# billzajac/ti99sim on GitHub (used in the two previous attempts)
# turned out to be a stale fork whose SDL frontend still targets
# the real SDL 1.2 API — confirmed live, it fails to compile
# against SDL2 outright on symbols SDL2 removed. RetroPie's own
# ti99sim.sh scriptmodule doesn't build from that fork at all:
# it fetches upstream v0.16.0 directly from the original
# author's own site (mrousseau.org), applies exactly one trivial
# patch (a missing #include <cstring> for modern g++), and
# builds straight against libsdl2-dev + libssl-dev with no SDL1
# compatibility layer — confirming v0.16.0 genuinely supports
# SDL2 natively, unlike the old fork. Verified the download URL
# actually serves the real tarball, and the patch content,
# before using either.
if docker run --rm -u root --entrypoint /bin/bash \
-v "$GAME_STORAGE_DIR/emulators:/output" \
ghcr.io/games-on-whales/es-de:edge -c '
set -e
apt-get update -qq
apt-get install -y -qq build-essential libsdl2-dev libssl-dev ca-certificates curl xz-utils
mkdir -p /tmp/ti99sim
curl -fsSL -o /tmp/ti99sim.tar.xz \
https://www.mrousseau.org/programs/ti99sim/archives/ti99sim-0.16.0.src.tar.xz
tar -xJf /tmp/ti99sim.tar.xz -C /tmp/ti99sim --strip-components 1
cd /tmp/ti99sim
sed -i "s/#include <regex>/#include <cstring>\n#include <regex>/" src/core/device-support.cpp
make
make install
cp /usr/local/bin/ti99sim-sdl /output/ti99sim-sdl
'; then
chmod +x "$GAME_STORAGE_DIR/emulators/ti99sim-sdl"
chown "$ACTUAL_USER:$ACTUAL_USER" "$GAME_STORAGE_DIR/emulators/ti99sim-sdl"
log_success "ti99sim-sdl built and installed -> $GAME_STORAGE_DIR/emulators/ti99sim-sdl"
else
log_warning "Build failed — check the docker output above. Retry later with: sudo ./setup.sh wolf"
fi
else
log_info "ti99sim-sdl already present — skipping build."
fi
# The one thing that can't be automated: the console ROM+GROM dump
# itself (real copyrighted firmware). Per RetroPie's own
# configure_ti99sim() — it symlinks this into the emulator's own
# install dir, then cd's there before launching — ti99sim-sdl looks
# for it as a plain relative file named TI-994A.ctg in the same
# directory the binary itself runs from, i.e. emulators/ here (the
# command line above now cd's into that directory first, matching
# RetroPie's own pushd + relative-launch pattern exactly).
local _TI99_BIOS_DST="$GAME_STORAGE_DIR/emulators/TI-994A.ctg"
if [ ! -f "$_TI99_BIOS_DST" ]; then
echo ""
log_info "TI-99/4A needs your own console ROM+GROM dump, named exactly TI-994A.ctg"
log_info "(case-sensitive), placed at: $_TI99_BIOS_DST"
if [ "$UNATTENDED" != true ]; then
local _TI99_BIOS_SRC=""
read -e -r -p " If you already have that file somewhere, enter its path now to copy it into place (Tab completes, Enter to skip): " -i "$GAME_STORAGE_DIR/" _TI99_BIOS_SRC
if [ -n "$_TI99_BIOS_SRC" ] && [ -f "$_TI99_BIOS_SRC" ]; then
cp "$_TI99_BIOS_SRC" "$_TI99_BIOS_DST"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_TI99_BIOS_DST"
log_success "Copied to $_TI99_BIOS_DST"
elif [ -n "$_TI99_BIOS_SRC" ]; then
log_warning "No file found at that path — skipped. Copy it to $_TI99_BIOS_DST whenever you have it."
else
log_info "Skipped — copy it to $_TI99_BIOS_DST whenever you have it."
fi
fi
fi
log_info "Then just drop .ctg/.rpk/.bin cartridge files into roms/ti994a/"
fi
# ── Optional: AntiMicroX gamepad -> keyboard/mouse remapping ───────────────
# Scoped to just TI-99/4A and Wii U (Cemu) — the two ES-DE systems that
# actually need it, not every system. TI-99/4A: ti99sim-sdl's own
# joystick handling (see the generated README's TI-99/4A Controls note)
# only ever emits digit keys 1-9 for a raw joystick button — there's no
# source-level path to 0, Enter, Q, or Esc from a gamepad. Wii U: Cemu's
# own controller support doesn't reliably distinguish two Wolf virtual
# pads that report the identical SDL GUID — AntiMicroX re-emits whatever
# it reads as its own distinct virtual device, a plausible angle on that
# worth having available (not a confirmed fix — needs live testing).
#
# Each system gets a SECOND, separately-labeled "(AntiMicroX)" <command>
# entry — ES-DE's own multi-command "alternative emulators" mechanism,
# the same pattern its bundled es_systems.xml already uses for e.g. the
# wii system's Dolphin vs. PrimeHack choice — alongside the existing
# default command, so nothing about the default launch path changes for
# anyone who doesn't explicitly pick the alternative.
#
# AntiMicroX has to inject synthetic keyboard/mouse events into the SAME
# Sway/Wayland session the game runs in. Its XTest backend needs
# Xwayland (not present in this container); its uinput backend
# (--eventgen uinput, added upstream in 3.1.7) needs /dev/uinput itself,
# which is why the esde catalog entry above now requests it via both
# GOW_REQUIRED_DEVICES and a real device grant — confirmed against
# AntiMicroX's own commandlineutility.cpp source for the exact flags
# (--no-tray, --hidden, --profile, --eventgen), not guessed.
echo ""
local _GET_ANTIMICROX=""
prompt_yn "Set up AntiMicroX (gamepad -> keyboard/mouse remapping) for TI-99/4A and Wii U (Cemu) in ES-DE? (y/n):" "n" _GET_ANTIMICROX
if [[ "$_GET_ANTIMICROX" =~ ^[Yy]$ ]]; then
_wolf_download_emulator_appimage \
"AntiMicroX (gamepad remapper)" "AntiMicroX/antimicrox" "*[Aa]nti[Mm]icro[Xx]*.AppImage" "$_EMU_DIR"
mkdir -p "$_EMU_DIR/antimicrox-profiles"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_EMU_DIR/antimicrox-profiles"
# Both wrappers are intentionally near-identical: start AntiMicroX
# hidden/no-tray (neither container has a desktop panel to dock a
# tray icon into) against a profile named after the system if one
# exists yet (it won't on a fresh install — build one via
# AntiMicroX's own GUI in the Desktop app, which shares this same
# ~/Applications mount, then save it into antimicrox-profiles/), exec
# the real emulator, and kill AntiMicroX again once it exits. This is
# what actually scopes remapping to just these two systems: nothing
# else in ES-DE launches through a wrapper, so nothing else is ever
# affected regardless of whether a profile exists.
cat > "$_EMU_DIR/ti99sim-sdl-antimicrox" << 'WRAPEOF'
#!/bin/bash
# Wraps ti99sim-sdl with AntiMicroX, scoped to just this system's own
# ES-DE launch command — see wolf.sh's AntiMicroX setup step.
DIR="$(cd "$(dirname "$0")" && pwd)"
PROFILE="$DIR/antimicrox-profiles/ti994a.gamecontroller.amgp"
AM_BIN=$(ls "$DIR"/*[Aa]nti[Mm]icro[Xx]*.AppImage 2>/dev/null | head -1)
AM_PID=""
if [ -n "$AM_BIN" ]; then
if [ -f "$PROFILE" ]; then
"$AM_BIN" --no-tray --hidden --eventgen uinput --profile "$PROFILE" &
else
"$AM_BIN" --no-tray --hidden --eventgen uinput &
fi
AM_PID=$!
sleep 1
fi
cleanup() { [ -n "$AM_PID" ] && kill "$AM_PID" 2>/dev/null; }
trap cleanup EXIT
cd "$DIR" || exit 1
exec ./ti99sim-sdl "$@"
WRAPEOF
chmod +x "$_EMU_DIR/ti99sim-sdl-antimicrox"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_EMU_DIR/ti99sim-sdl-antimicrox"
cat > "$_EMU_DIR/cemu-antimicrox" << 'WRAPEOF'
#!/bin/bash
# Wraps Cemu with AntiMicroX, scoped to just the Wii U system's own ES-DE
# launch command — see wolf.sh's AntiMicroX setup step.
DIR="$(cd "$(dirname "$0")" && pwd)"
PROFILE="$DIR/antimicrox-profiles/wiiu.gamecontroller.amgp"
AM_BIN=$(ls "$DIR"/*[Aa]nti[Mm]icro[Xx]*.AppImage 2>/dev/null | head -1)
CEMU_BIN=$(ls "$DIR"/*[Cc]emu*.AppImage 2>/dev/null | head -1)
if [ -z "$CEMU_BIN" ]; then
echo "cemu-antimicrox: no Cemu*.AppImage found in $DIR" >&2
exit 1
fi
AM_PID=""
if [ -n "$AM_BIN" ]; then
if [ -f "$PROFILE" ]; then
"$AM_BIN" --no-tray --hidden --eventgen uinput --profile "$PROFILE" &
else
"$AM_BIN" --no-tray --hidden --eventgen uinput &
fi
AM_PID=$!
sleep 1
fi
cleanup() { [ -n "$AM_PID" ] && kill "$AM_PID" 2>/dev/null; }
trap cleanup EXIT
exec "$CEMU_BIN" "$@"
WRAPEOF
chmod +x "$_EMU_DIR/cemu-antimicrox"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_EMU_DIR/cemu-antimicrox"
log_success "AntiMicroX wrapper scripts written: $_EMU_DIR/{ti99sim-sdl-antimicrox,cemu-antimicrox}"
mkdir -p "$GAME_STORAGE_DIR/esde-custom-systems"
# es_find_rules.xml: register CEMU_AM here (TI99SIM_AM is registered
# by the TI-99/4A system-setup step above, since it only means
# anything once that system exists). Same strip-and-reappend
# idempotency as every other custom_systems write in this file.
local _AM_RULES="$GAME_STORAGE_DIR/esde-custom-systems/es_find_rules.xml"
backup_if_exists "$_AM_RULES"
python3 - "$_AM_RULES" << 'AMRULESPY'
import re, sys
path = sys.argv[1]
try:
with open(path) as f:
content = f.read()
except FileNotFoundError:
content = '<ruleList>\n</ruleList>\n'
content = re.sub(r'[ \t]*<emulator name="CEMU_AM">.*?</emulator>[ \t]*\n?', '', content, flags=re.DOTALL)
block = ''' <emulator name="CEMU_AM">
<rule type="staticpath">
<entry>~/Applications/cemu-antimicrox</entry>
</rule>
</emulator>
'''
content = content.replace('</ruleList>', block + '</ruleList>')
with open(path, 'w') as f:
f.write(content)
AMRULESPY
chown "$ACTUAL_USER:$ACTUAL_USER" "$_AM_RULES"
# es_systems.xml: wiiu has no existing custom_systems override (it's
# one of ES-DE's own built-in systems, confirmed against ES-DE's own
# bundled resources/systems/linux/es_systems.xml) — per ES-DE's own
# USERGUIDE.md, a custom_systems entry for a system name that
# already exists in the bundled config REPLACES that system's whole
# definition rather than merging into it, so this has to replicate
# every field, not just add a line. fullname/path/extension/
# platform/theme and the original "Cemu (Standalone)" command below
# are copied verbatim from ES-DE's real bundled es_systems.xml so
# the default launch path stays byte-for-byte identical to the
# built-in one; only the new AntiMicroX alternative command is
# actually new.
local _WIIU_XML="$GAME_STORAGE_DIR/esde-custom-systems/es_systems.xml"
backup_if_exists "$_WIIU_XML"
python3 - "$_WIIU_XML" << 'WIIUXMLPY'
import re, sys
path = sys.argv[1]
try:
with open(path) as f:
content = f.read()
except FileNotFoundError:
content = '<systemList>\n</systemList>\n'
content = re.sub(r'[ \t]*<system>\s*<name>wiiu</name>.*?</system>[ \t]*\n?', '', content, flags=re.DOTALL)
block = ''' <system>
<name>wiiu</name>
<fullname>Nintendo Wii U</fullname>
<path>%ROMPATH%/wiiu</path>
<extension>.elf .ELF .rpx .RPX .tmd .TMD .wua .WUA .wud .WUD .wuhb .WUHB .wux .WUX</extension>
<command label="Cemu (Standalone)">%EMULATOR_CEMU% -g %ROM%</command>
<command label="Cemu (AntiMicroX)">%INJECT%=%BASENAME%.esprefix %STARTDIR%=%EMUDIR% %EMULATOR_CEMU_AM% -g %ROM%</command>
<platform>wiiu</platform>
<theme>wiiu</theme>
</system>
'''
content = content.replace('</systemList>', block + '</systemList>')
with open(path, 'w') as f:
f.write(content)
WIIUXMLPY
chown "$ACTUAL_USER:$ACTUAL_USER" "$_WIIU_XML"
log_success "AntiMicroX alternate launch commands added for Wii U (and TI-99/4A, if set up above) in ES-DE"
log_info "No profiles exist yet — build them via AntiMicroX's own GUI in the Desktop app (it shares"
log_info "this same ~/Applications mount), save as antimicrox-profiles/ti994a.gamecontroller.amgp"
log_info "and/or wiiu.gamecontroller.amgp, then pick the '(AntiMicroX)' alternate command for that"
log_info "system in ES-DE (per-game or per-system, via its own 'Alternative emulators' option)."
fi
# ── PS2: backfill the PCEE2 RetroArch core as an alternative emulator ──────
# ghcr.io/games-on-whales/es-de:edge only ever installs ES-DE's latest
# RELEASED AppImage (confirmed against gow's own apps/es-de/build/Dockerfile
# — it queries GitLab's releases API for the newest tag, not git master),
# which is currently v3.4.1. PCEE2 (a separate, actively-developed libretro
# PCSX2 port, WizzardSK/pcee2-libretro) was only added to ES-DE's own
# bundled es_systems.xml under "Version 3.5.0 (in development)" — confirmed
# against ES-DE's real CHANGELOG.md and by diffing the v3.4.1 tag's actual
# ps2 system block against git master's: identical except for this one
# missing <command> line. So on the version this container actually runs,
# "PCEE2" genuinely isn't in the alternative-emulators list at all yet —
# not a missing download, a missing menu entry. This backfills it (as the
# new default, matching upstream's own placement) while every other field
# and command is copied verbatim from the real v3.4.1 block, so nothing
# else about the ps2 system changes. The actual pcee2_libretro.so core
# file doesn't need any extra download step here — confirmed live against
# the libretro buildbot's own index that it's already covered by this
# installer's normal "./manage.sh cores all" (the default RetroArch core
# pre-download prompt below), same as every other libretro core.
mkdir -p "$GAME_STORAGE_DIR/esde-custom-systems"
local _PS2_XML="$GAME_STORAGE_DIR/esde-custom-systems/es_systems.xml"
backup_if_exists "$_PS2_XML"
python3 - "$_PS2_XML" << 'PS2XMLPY'
import re, sys
path = sys.argv[1]
try:
with open(path) as f:
content = f.read()
except FileNotFoundError:
content = '<systemList>\n</systemList>\n'
content = re.sub(r'[ \t]*<system>\s*<name>ps2</name>.*?</system>[ \t]*\n?', '', content, flags=re.DOTALL)
block = ''' <system>
<name>ps2</name>
<fullname>Sony PlayStation 2</fullname>
<path>%ROMPATH%/ps2</path>
<extension>.bin .BIN .chd .CHD .ciso .CISO .cso .CSO .desktop .dump .DUMP .elf .ELF .gz .GZ .m3u .M3U .mdf .MDF .img .IMG .iso .ISO .isz .ISZ .ngr .NRG .zso .ZSO</extension>
<command label="PCEE2">%EMULATOR_RETROARCH% -L %CORE_RETROARCH%/pcee2_libretro.so %ROM%</command>
<command label="LRPS2">%EMULATOR_RETROARCH% -L %CORE_RETROARCH%/pcsx2_libretro.so %ROM%</command>
<command label="PCSX2">%EMULATOR_RETROARCH% -L %CORE_RETROARCH%/pcsx2_libretro.so %ROM%</command>
<command label="PCSX2 (Standalone)">%EMULATOR_PCSX2% -batch %ROM%</command>
<command label="PCSX2 Legacy (Standalone)">%EMULATOR_PCSX2-LEGACY% --nogui %ROM%</command>
<command label="Play! (Standalone)">%EMULATOR_PLAY!% --fullscreen --disc %ROM%</command>
<command label="Shortcut or script">%ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM%</command>
<platform>ps2</platform>
<theme>ps2</theme>
</system>
'''
content = content.replace('</systemList>', block + '</systemList>')
with open(path, 'w') as f:
f.write(content)
PS2XMLPY
chown "$ACTUAL_USER:$ACTUAL_USER" "$_PS2_XML"
log_success "PS2: added PCEE2 as the default RetroArch core (custom_systems/es_systems.xml) — ES-DE's"
log_info "own build doesn't ship it yet (added upstream in the not-yet-released 3.5.0)"
# ── ES-DE: force "Run in background" off ────────────────────────────────
# ES-DE's own compiled default for this IS already off (confirmed against
# its real Settings.cpp: mBoolMap["RunInBackground"] = {false, false}) —
# this write exists purely so that default stays durable and explicit
# rather than relying on it never having been toggled. Real, confirmed
# cause of a genuine bug otherwise: with it on, ES-DE keeps running and
# listening to every controller even after a game/emulator has launched
# and taken visual focus — a second controller's input can still reach
# ES-DE's own menu in the background and launch (and start playing audio
# for) a completely different game while the first one is still up
# front. ES-DE's own USERGUIDE.md explicitly names this failure mode
# ("make sure that the setting Run in background... is disabled").
# Confirmed live: this affects any system, not just Cemu.
#
# es_settings.xml is the user's OWN full settings state (theme, scraper
# prefs, everything) — unlike es_systems.xml this is never a full
# rewrite, only a surgical strip-and-reappend of this one <bool> entry,
# same idempotent pattern as everywhere else in this file. Written as a
# plain top-level element with no <settings> wrapper, matching what
# ES-DE's own Settings::saveFile() actually produces today (confirmed
# against its source — the "wrap everything in <settings>" format is
# loader-side forward-compatibility for a future ES-DE release, not
# what gets written now); either format loads fine either way.
mkdir -p "$GAME_STORAGE_DIR/esde-settings"
_ESDE_SETTINGS="$GAME_STORAGE_DIR/esde-settings/es_settings.xml"
backup_if_exists "$_ESDE_SETTINGS"
python3 - "$_ESDE_SETTINGS" << 'ESDESETTINGSPY'
import re, sys
path = sys.argv[1]
try:
with open(path) as f:
content = f.read()
except FileNotFoundError:
content = ''
content = re.sub(r'[ \t]*<bool name="RunInBackground" value="[^"]*"[ \t]*/>[ \t]*\n?', '', content)
line = ' <bool name="RunInBackground" value="false" />\n'
content = (content.rstrip('\n') + '\n' + line) if content.strip() else line
with open(path, 'w') as f:
f.write(content)
ESDESETTINGSPY
chown "$ACTUAL_USER:$ACTUAL_USER" "$_ESDE_SETTINGS"
log_success "ES-DE: 'Run in background (while game is launched)' forced off (esde-settings/es_settings.xml)"
# ── App selection ─────────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════════════════"
echo " WOLF APPS"
echo "═══════════════════════════════════════════════════════"
echo ""
echo " Select apps to add to Wolf (shown in Moonlight)."
echo " Containers are pulled on first launch, not now."
echo ""
echo " 1) Steam - Big Picture + Proton (PC games)"
echo " 2) EmulationStation - ES-DE + RetroArch (retro ROMs) [default]"
echo " 3) Lutris - GOG / Epic / Wine / non-Steam"
echo " 4) RetroArch - standalone emulator frontend"
echo " 5) Prism Launcher - Minecraft (Java + Bedrock)"
echo " 6) Kodi - media center"
echo " 7) Firefox - browser"
echo " 8) Desktop - full XFCE desktop session"
echo ""
echo " Enter numbers separated by spaces, or 'all', or Enter for [1 2]:"
echo ""
local _APP_PICKS="" _SELECTED_APPS=()
if [ "$UNATTENDED" = true ]; then
_SELECTED_APPS=(1 2)
log_info "Unattended — selecting Steam + EmulationStation"
else
read -r -p " Apps [1 2]: " _APP_PICKS
_APP_PICKS="${_APP_PICKS:-1 2}"
if [[ "${_APP_PICKS,,}" == "all" ]]; then
_SELECTED_APPS=(1 2 3 4 5 6 7 8)
else
read -ra _SELECTED_APPS <<< "$_APP_PICKS"
fi
fi
# Map numbers to app keys for the Python injector
local _APP_KEYS=""
for _n in "${_SELECTED_APPS[@]}"; do
case "$_n" in
1) _APP_KEYS="$_APP_KEYS steam" ;;
2) _APP_KEYS="$_APP_KEYS esde" ;;
3) _APP_KEYS="$_APP_KEYS lutris" ;;
4) _APP_KEYS="$_APP_KEYS retroarch" ;;
5) _APP_KEYS="$_APP_KEYS prismlauncher" ;;
6) _APP_KEYS="$_APP_KEYS kodi" ;;
7) _APP_KEYS="$_APP_KEYS firefox" ;;
8) _APP_KEYS="$_APP_KEYS desktop" ;;
esac
done
_APP_KEYS="${_APP_KEYS# }" # trim leading space
log_info "Will add: ${_APP_KEYS:-none}"
# ── docker-compose.yml ────────────────────────────────────────────────────
log_info "Generating docker-compose.yml..."
mkdir -p "$WOLF_STATE_DIR/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
# Write compose using a mixed heredoc: WOLF_STATE_DIR comes from .env at
# runtime (docker compose variable substitution), so it stays correct even
# if the drive is remounted at a different path. IP/MAC/render-node are
# baked in at install time because they're hardware-specific and not stored
# in .env — use 'manage.sh update-network' to regenerate if they change.
backup_if_exists docker-compose.yml
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
# WOLF_STATE_DIR is read from .env at runtime — edit .env to relocate.
# Must be the same path inside and outside the container so that app
# containers Wolf spawns via the Docker socket resolve it on the host.
- HOST_APPS_STATE_FOLDER=\${WOLF_STATE_DIR}
- WOLF_CFG_FOLDER=\${WOLF_STATE_DIR}/cfg
- 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
# Exposes Wolf's REST API socket at this same path on the HOST (matches
# Wolf's own docs' recommended pattern exactly) — without this it only
# exists inside the container at its default \$XDG_RUNTIME_DIR-relative
# path, unreachable from manage.sh. Used by 'manage.sh controllers' to
# set per-client controllers_override (see that command's own comments
# for why this exists — distinguishing multiple same-model virtual
# gamepads, e.g. two Wii U Pro Controllers, so games like Cemu can
# actually tell them apart).
- WOLF_SOCKET_PATH=/var/run/wolf/wolf.sock
volumes:
- \${WOLF_STATE_DIR}:\${WOLF_STATE_DIR}:rw
- /var/run/docker.sock:/var/run/docker.sock:rw
- /dev/:/dev/:rw
- /run/udev:/run/udev:rw
- /var/run/wolf:/var/run/wolf: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"
# Save the game storage path so it's visible and editable later
backup_if_exists .env
cat > .env << EOF
# Wolf game/ROM storage root — edit this and run ./manage.sh update-storage to apply
GAME_STORAGE_DIR=${GAME_STORAGE_DIR}
# Wolf state folder (config.toml + every app session home) — lives on the game
# drive so Steam installs, games, and Proton prefixes stay off the OS drive.
WOLF_STATE_DIR=${WOLF_STATE_DIR}
# Timezone for app containers (Steam/Proton etc.) — set on each app's TZ env so
# Steam, EA App, and games show the correct local time. Edit + ./manage.sh apps.
WOLF_TZ=${SITE_TZ}
EOF
chmod 600 .env
chown "$ACTUAL_USER:$ACTUAL_USER" .env
log_success ".env written with GAME_STORAGE_DIR=${GAME_STORAGE_DIR}"
# ── 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
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Wolf's state folder (config.toml + every app session home, Steam install,
# games, Proton prefixes) lives on the game drive — see WOLF_STATE_DIR in .env.
# Nothing here needs to relocate or symlink anything; Wolf writes straight to
# the drive because docker-compose.yml mounts it as HOST_APPS_STATE_FOLDER.
WOLF_STATE_DIR=$(grep '^WOLF_STATE_DIR=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2-)
WOLF_CFG="${WOLF_STATE_DIR:-/etc/wolf}/cfg/config.toml"
WOLF_TZ=$(grep '^WOLF_TZ=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2-)
# Locate the Steam home under the Wolf state folder (born on first Steam launch).
_steam_home() {
# Prefer the Steam home whose container is currently running.
# With multiple WolfSteam containers, head -1 picks the wrong one.
local candidate running_ids
running_ids=$(docker ps --format '{{.Names}}' | grep -i WolfSteam | sed 's/WolfSteam_//')
while IFS= read -r candidate; do
local cid
cid=$(basename "$(dirname "$candidate")")
if echo "$running_ids" | grep -qF "$cid"; then
echo "$candidate"
return 0
fi
done < <(find "${WOLF_STATE_DIR:-/etc/wolf}" -maxdepth 2 -type d -name Steam 2>/dev/null)
# Fallback: first found
find "${WOLF_STATE_DIR:-/etc/wolf}" -maxdepth 2 -type d -name Steam 2>/dev/null | head -1
}
# Download the GE-Proton tarball to a local cache dir so it is ready to
# extract the moment Steam has launched (no re-download needed).
# Idempotent — skips the download if the cache file already exists.
_cache_ge_proton() {
local version="${1:-}"
local cache_dir="$SCRIPT_DIR/ge-proton-cache"
mkdir -p "$cache_dir"
local url name
if [ -n "$version" ]; then
url="https://github.com/GloriousEggroll/proton-ge-custom/releases/download/$version/$version.tar.gz"
else
echo "Fetching latest GE-Proton release info..."
url=$(curl -sL https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases/latest \
| grep browser_download_url | grep '\.tar\.gz' | cut -d'"' -f4)
if [ -z "$url" ]; then
echo "Could not determine GE-Proton download URL (GitHub rate-limited or offline?)."
return 1
fi
fi
name=$(basename "$url" .tar.gz)
local cached="$cache_dir/$name.tar.gz"
if [ -f "$cached" ]; then
echo "GE-Proton already cached: $cached"
echo "$name" > "$cache_dir/.version"
return 0
fi
echo "Downloading $name (~500 MB) to cache..."
if curl -L -o "$cached" "$url"; then
echo "$name" > "$cache_dir/.version"
echo "Cached: $cached"
else
echo "Download failed."; rm -f "$cached"; return 1
fi
}
# Pre-satisfy the EA App install-script markers in a game's Proton prefix so
# Steam stops looping on "running install script (EA app)". The EA Desktop
# InstallSuccessful flag is shared across all EA titles; the Valve has-run key
# is per-AppID but identical in form for every EA game — so this works for any
# of them. Safe + idempotent; only touches the AppID passed in.
#
# IMPORTANT: wineserver holds registry state in memory and flushes it back to
# disk, overwriting any edits made while the container is running. We stop the
# WolfSteam container first, patch the files on disk, then restart — so the
# keys survive intact when wineserver next starts.
_apply_ea_fix() {
local appid="$1"
local steam_home reg acf container_was_running=0
# Search all Wolf Steam homes for the one that has this AppID's prefix.
# With multiple WolfSteam containers, head -1 picks the wrong one.
steam_home=""
while IFS= read -r candidate; do
if [ -f "$candidate/.steam/steam/steamapps/compatdata/$appid/pfx/system.reg" ]; then
steam_home="$candidate"
break
fi
done < <(find "${WOLF_STATE_DIR:-/etc/wolf}" -maxdepth 2 -type d -name Steam 2>/dev/null)
[ -z "$steam_home" ] && steam_home=$(_steam_home)
reg="$steam_home/.steam/steam/steamapps/compatdata/$appid/pfx/system.reg"
acf="$steam_home/.steam/steam/steamapps/appmanifest_${appid}.acf"
if [ ! -f "$reg" ]; then
echo "No Proton prefix for AppID $appid yet (looked for $reg)."
echo "In Steam: set the game to GE-Proton (./manage.sh ge-proton),"
echo "click Play once to build the prefix, then re-run this."
return 1
fi
# Stop the Steam container so wineserver is not running when we edit the
# registry. We restart it at the end so the user's session resumes.
local steam_container
steam_container=$(docker ps --format '{{.Names}}' | grep -i 'WolfSteam' | head -1)
if [ -n "$steam_container" ]; then
echo "Stopping $steam_container so wineserver is not running during patch..."
docker stop "$steam_container" >/dev/null
container_was_running=1
sleep 2
fi
# Locate EA App executables inside the Proton prefix.
# Prefer EADesktop.exe (full EA App install); fall back to Link2EA.exe (stub or partial).
local pfx_root
pfx_root="$steam_home/.steam/steam/steamapps/compatdata/$appid/pfx/drive_c"
local ureg
ureg="$steam_home/.steam/steam/steamapps/compatdata/$appid/pfx/user.reg"
local ea_exe link2ea_exe
ea_exe=$(sudo find "$pfx_root" -maxdepth 8 \
-ipath "*/Electronic Arts/EA Desktop/EA Desktop/EADesktop.exe" \
-print -quit 2>/dev/null)
link2ea_exe=$(sudo find "$pfx_root" -maxdepth 8 \
-ipath "*/Electronic Arts/EA Desktop/*/EA Desktop/Link2EA.exe" \
-print -quit 2>/dev/null)
# check both system.reg AND user.reg for existing link2ea registration
local _has_link2ea=0
{ sudo grep -q 'link2ea' "$reg" 2>/dev/null || sudo grep -q 'link2ea' "$ureg" 2>/dev/null; } \
&& _has_link2ea=1
if sudo grep -q 'EADesktopSetup' "$reg" 2>/dev/null && [ "$_has_link2ea" = 1 ]; then
echo "Registry keys already present in AppID $appid prefix."
if [ -n "$ea_exe" ]; then
echo " EA App: $ea_exe"
elif [ -n "$link2ea_exe" ]; then
echo " Link2EA.exe: $link2ea_exe"
echo " NOTE: Full EA App not yet installed. Run ./manage.sh install-ea-app to install it."
fi
else
echo "Patching EA install-script markers for AppID $appid..."
# Wine v2 registry format uses doubled backslashes as path separator.
# Wow6432Node version is the one wineserver preserves on flush;
# Steam's HasRunStringKey check is satisfied by the 32-bit view.
{
printf '\n[Software\\\\Electronic Arts\\\\EA Desktop] 1781772837\n"InstallSuccessful"="true"\n'
printf '\n[Software\\\\Wow6432Node\\\\Electronic Arts\\\\EA Desktop] 1781772837\n"InstallSuccessful"="true"\n'
printf '\n[Software\\\\Valve\\\\Steam\\\\Apps\\\\%s] 1781772837\n"EADesktopSetup"=dword:00000001\n' "$appid"
printf '\n[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\%s] 1781772837\n"EADesktopSetup"=dword:00000001\n' "$appid"
# Register the link2ea:// URL protocol handler in system.reg (HKLM).
# SWBF2 calls ShellExecute("link2ea://launchgame/...") and exits; the
# EA App (EADesktop.exe) re-authenticates and re-launches the game.
# GE-Proton's steam.exe intercepts link2ea:// and forwards to Link2EA.exe.
# NOTE: user.reg (HKCU) takes priority; GE-Proton may already have registered
# this there during prefix setup. Check with: ./manage.sh diagnose-ea
local _handler_exe=""
if [ -n "$ea_exe" ]; then
_handler_exe="$ea_exe"
echo " Using real EA App: $ea_exe"
elif [ -n "$link2ea_exe" ]; then
_handler_exe="$link2ea_exe"
echo " Using Link2EA.exe stub: $link2ea_exe"
echo " NOTE: Run ./manage.sh install-ea-app to install full EA App."
fi
if [ -n "$_handler_exe" ]; then
local win_path
win_path=$(echo "$_handler_exe" \
| sed "s|$pfx_root||" \
| sed 's|/|\\\\|g' \
| sed 's|^|C:|')
printf '\n[Software\\\\Classes\\\\link2ea] 1781772837\n@="link2ea Protocol"\n"URL Protocol"=""\n'
printf '\n[Software\\\\Classes\\\\link2ea\\\\shell] 1781772837\n'
printf '\n[Software\\\\Classes\\\\link2ea\\\\shell\\\\open] 1781772837\n'
printf '\n[Software\\\\Classes\\\\link2ea\\\\shell\\\\open\\\\command] 1781772837\n@="%s \\"%%1\\""\n' "$win_path"
printf '\n[Software\\\\Wow6432Node\\\\Classes\\\\link2ea] 1781772837\n@="link2ea Protocol"\n"URL Protocol"=""\n'
printf '\n[Software\\\\Wow6432Node\\\\Classes\\\\link2ea\\\\shell] 1781772837\n'
printf '\n[Software\\\\Wow6432Node\\\\Classes\\\\link2ea\\\\shell\\\\open] 1781772837\n'
printf '\n[Software\\\\Wow6432Node\\\\Classes\\\\link2ea\\\\shell\\\\open\\\\command] 1781772837\n@="%s \\"%%1\\""\n' "$win_path"
echo " link2ea:// handler registered in system.reg → $win_path"
else
echo " WARNING: EA App not found (neither EADesktop.exe nor Link2EA.exe)."
echo " link2ea:// handler NOT registered in system.reg."
echo " GE-Proton may have already registered it in user.reg — check with:"
echo " ./manage.sh diagnose-ea $appid"
echo " To install EA App: ./manage.sh install-ea-app $appid"
fi
} | sudo tee -a "$reg" >/dev/null
sudo chown 1000:1000 "$reg"
echo " Registry patched."
fi
# Reset StateFlags to 4 (fully installed) so Steam won't re-run install scripts.
if [ -f "$acf" ]; then
sudo sed -i 's/"StateFlags"\s*"[0-9]*"/"StateFlags"\t\t"4"/' "$acf"
# StateFlags 4 = update required (re-runs install scripts); 6 = fully installed.
# We want 6 so Steam launches the game directly.
sudo sed -i 's/"StateFlags"\t\t"4"/"StateFlags"\t\t"6"/' "$acf"
sudo chown 1000:1000 "$acf"
echo " StateFlags set to 6 (fully installed)."
fi
if [ "$container_was_running" = 1 ]; then
echo "Restarting Wolf (Steam will reconnect automatically)..."
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d >/dev/null 2>&1 || \
docker start "$steam_container" >/dev/null 2>&1 || true
echo " Wolf restarted. Reconnect from Moonlight and click Play."
fi
echo "Done. In Steam: if the 'running install script' screen appears, cancel it, then Play."
}
case "$1" in
start)
docker compose up -d
echo "Wolf started. Pair Moonlight to this server's IP."
# If GE-Proton is cached but not yet extracted (Steam launched since
# install), extract it now so it appears in the Compatibility dropdown.
_STEAM_H=$(_steam_home)
if [ -n "$_STEAM_H" ]; then
_COMPAT="$_STEAM_H/.steam/compatibilitytools.d"
_CACHE="$SCRIPT_DIR/ge-proton-cache"
_CACHED_VER=""
[ -f "$_CACHE/.version" ] && _CACHED_VER=$(cat "$_CACHE/.version")
if [ -n "$_CACHED_VER" ] && [ -f "$_CACHE/$_CACHED_VER.tar.gz" ] \
&& [ ! -d "$_COMPAT/$_CACHED_VER" ]; then
echo "Extracting cached GE-Proton $_CACHED_VER..."
sudo mkdir -p "$_COMPAT"
sudo tar -xzf "$_CACHE/$_CACHED_VER.tar.gz" -C "$_COMPAT"
sudo chown -R 1000:1000 "$_COMPAT"
echo "GE-Proton $_CACHED_VER ready. In Steam: game → Properties → Compatibility → Force $_CACHED_VER"
fi
fi
;;
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
;;
apps|add-apps|update-storage)
# WOLF_CFG resolved at top of script from .env
# Resolve game storage dir: explicit arg > .env > prompt
GAME_DIR="${2:-}"
if [ -z "$GAME_DIR" ]; then
GAME_DIR=$(grep '^GAME_STORAGE_DIR=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2-)
fi
if [ -z "$GAME_DIR" ]; then
read -r -p " Game storage path: " GAME_DIR
fi
if [ -z "$GAME_DIR" ]; then echo "No game storage path."; exit 1; fi
# Persist path back to .env if it changed
if grep -qs '^GAME_STORAGE_DIR=' "$SCRIPT_DIR/.env" 2>/dev/null; then
sed -i "s|^GAME_STORAGE_DIR=.*|GAME_STORAGE_DIR=${GAME_DIR}|" "$SCRIPT_DIR/.env"
else
echo "GAME_STORAGE_DIR=${GAME_DIR}" >> "$SCRIPT_DIR/.env"
fi
# Ensure steam-cache dir exists (persists DXVK/Mesa shader cache across sessions)
mkdir -p "$GAME_DIR/steam-cache"
sudo chown -R 1000:1000 "$GAME_DIR/steam-cache" 2>/dev/null || true
if [ ! -f "$WOLF_CFG" ]; then
echo "Wolf config not found at $WOLF_CFG — is Wolf running?"
exit 1
fi
# Show which apps are already installed and what's available
echo ""
echo " Checking current Wolf config..."
# Any amount of leading whitespace (or none) — Wolf's own TOML writer
# doesn't reliably indent with exactly 4 spaces, and a stricter
# anchor here silently reports "No Wolf apps installed yet" even
# when apps clearly are (confirmed live), which then makes the
# Enter/"update mounts only" path fall back to the hardcoded
# steam+esde default instead of actually refreshing what's there.
INSTALLED=$(sudo grep -E "^[[:space:]]*name = 'Wolf" "$WOLF_CFG" 2>/dev/null | sed "s/.*name = '//;s/'//" | tr '\n' ' ')
[ -n "$INSTALLED" ] && echo " Already installed: $INSTALLED" || echo " No Wolf apps installed yet."
echo ""
echo " Available apps:"
echo " 1) Steam - Big Picture + Proton (PC games)"
echo " 2) EmulationStation - ES-DE + RetroArch (retro ROMs)"
echo " 3) Lutris - GOG / Epic / Wine / non-Steam"
echo " 4) RetroArch - standalone emulator frontend"
echo " 5) Prism Launcher - Minecraft (Java + Bedrock)"
echo " 6) Kodi - media center"
echo " 7) Firefox - browser"
echo " 8) Desktop - full XFCE desktop session"
echo ""
echo " Enter numbers (space-separated), 'all', or Enter to update existing mounts only:"
read -r -p " Apps [Enter=update mounts only]: " _PICKS
if [[ "${_PICKS,,}" == "all" ]]; then
APP_KEYS="steam esde lutris retroarch prismlauncher kodi firefox desktop"
elif [ -z "$_PICKS" ]; then
# No new apps — just re-run with whatever keys are already installed
APP_KEYS=""
[[ "$INSTALLED" == *WolfSteam* ]] && APP_KEYS="$APP_KEYS steam"
[[ "$INSTALLED" == *WolfES-DE* ]] && APP_KEYS="$APP_KEYS esde"
[[ "$INSTALLED" == *WolfLutris* ]] && APP_KEYS="$APP_KEYS lutris"
[[ "$INSTALLED" == *WolfRetroArch* ]] && APP_KEYS="$APP_KEYS retroarch"
[[ "$INSTALLED" == *WolfPrismLauncher* ]] && APP_KEYS="$APP_KEYS prismlauncher"
[[ "$INSTALLED" == *WolfKodi* ]] && APP_KEYS="$APP_KEYS kodi"
[[ "$INSTALLED" == *WolfFirefox* ]] && APP_KEYS="$APP_KEYS firefox"
[[ "$INSTALLED" == *WolfDesktop* ]] && APP_KEYS="$APP_KEYS desktop"
APP_KEYS="${APP_KEYS# }"
[ -z "$APP_KEYS" ] && APP_KEYS="steam esde"
else
APP_KEYS=""
for _n in $_PICKS; do
case "$_n" in
1) APP_KEYS="$APP_KEYS steam" ;;
2) APP_KEYS="$APP_KEYS esde" ;;
3) APP_KEYS="$APP_KEYS lutris" ;;
4) APP_KEYS="$APP_KEYS retroarch" ;;
5) APP_KEYS="$APP_KEYS prismlauncher" ;;
6) APP_KEYS="$APP_KEYS kodi" ;;
7) APP_KEYS="$APP_KEYS firefox" ;;
8) APP_KEYS="$APP_KEYS desktop" ;;
esac
done
APP_KEYS="${APP_KEYS# }"
fi
echo " Applying: ${APP_KEYS:-none}"
[ -z "$APP_KEYS" ] && exit 0
sudo python3 - "$GAME_DIR" "$WOLF_CFG" "$WOLF_TZ" $APP_KEYS << 'PYEOF'
import sys, json
games = sys.argv[1].rstrip('/')
cfg = sys.argv[2]
TZ = sys.argv[3].strip()
selected = set(sys.argv[4:])
STD_CAP = ['NET_RAW', 'MKNOD', 'NET_ADMIN']
STD_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*']
STD_RULES = ['c 13:* rmw', 'c 244:* rmw']
# ES-DE gets its own env: same as STD_ENV but with /dev/uinput added to
# GOW_REQUIRED_DEVICES (a second, duplicate-keyed GOW_REQUIRED_DEVICES entry
# alongside STD_ENV's own would be ambiguous to whichever engine reads it, so
# this is a full replacement, not an addition to STD_ENV) — AntiMicroX (see
# the antimicrox setup step below) needs to open /dev/uinput itself to inject
# synthetic key/mouse events under Sway/Wayland (XTest, its other backend,
# needs Xwayland, which this container doesn't run). Paired with
# devices=['/dev/uinput:/dev/uinput'] on the esde catalog entry itself below
# — GOW_REQUIRED_DEVICES alone only gets the base image's own entrypoint
# script to bind-mount the node; the container also needs Wolf's own
# create-time device grant to actually open it (matches the real
# games-on-whales/wolf config.toml example for Steam's own uinput access).
ESDE_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/uinput /dev/input/* /dev/dri/* /dev/nvidia*']
CATALOG = {
'steam': dict(
name='WolfSteam', title='Steam',
icon='https://games-on-whales.github.io/wildlife/apps/steam/assets/icon.png',
image='ghcr.io/games-on-whales/steam:edge',
mounts=['/etc/localtime:/etc/localtime:ro', '/etc/timezone:/etc/timezone:ro',
f'{games}/steam-cache:/home/retro/.cache:rw'],
env=['PROTON_LOG=1', 'RUN_SWAY=true',
'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'],
cap_add=['SYS_ADMIN', 'SYS_NICE', 'SYS_PTRACE', 'NET_RAW', 'MKNOD', 'NET_ADMIN'],
security_opt=['seccomp=unconfined', 'apparmor=unconfined'],
ipc_mode='host', ulimits=[{'Name': 'nofile', 'Hard': 10240, 'Soft': 10240}],
privileged=False,
),
'esde': dict(
name='WolfES-DE', title='EmulationStation',
icon='https://games-on-whales.github.io/wildlife/apps/es-de/assets/icon.png',
image='ghcr.io/games-on-whales/es-de:edge',
mounts=[f'{games}/roms:/ROMs:rw',
f'{games}/saves:/mnt/games/saves:rw',
f'{games}/media:/media:rw',
f'{games}/bios:/home/retro/bioses:rw',
f'{games}/retro-home:/home/retro/.config:rw',
f'{games}/retro-home-data:/home/retro/.local/share:rw',
f'{games}/retroarch:/home/retro/.config/retroarch:rw',
f'{games}/emulators:/mnt/games/emulators:rw',
f'{games}/emulators:/home/retro/Applications:rw',
f'{games}/esde-custom-systems:/home/retro/ES-DE/custom_systems:rw',
# ~/ES-DE (settings, gamelists, downloaded_media, logs) has
# NO mount at all otherwise (confirmed against ES-DE's own
# source and GOW's es-de startup.sh: getAppDataDirectory()
# is a plain $HOME/ES-DE, no XDG redirect) — Wolf normally
# reuses the same app container across sessions rather than
# recreating it each time (confirmed against Wolf's own
# docker.cpp: it only removes the container on session end
# if WOLF_STOP_CONTAINER_ON_EXIT=TRUE, which this repo never
# sets), so this doesn't get wiped every reconnect — but it
# IS lost on any real reinstall/container recreate, unlike
# everything else here which lives on the game drive. Only
# mounting settings/ specifically (not the whole ~/ES-DE
# tree) keeps this additive and non-breaking alongside the
# existing custom_systems mount above — gamelists/scraped
# media durability is a separate, not-yet-done improvement.
f'{games}/esde-settings:/home/retro/ES-DE/settings:rw'],
env=ESDE_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host',
ulimits=[], privileged=False,
devices=['/dev/uinput:/dev/uinput'],
),
'lutris': dict(
name='WolfLutris', title='Lutris',
icon='https://games-on-whales.github.io/wildlife/apps/lutris/assets/icon.png',
image='ghcr.io/games-on-whales/lutris:edge',
mounts=[f'{games}/lutris:/mnt/games/lutris:rw'],
env=['RUN_SWAY=true',
'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'],
cap_add=['SYS_ADMIN', 'NET_RAW', 'MKNOD', 'NET_ADMIN'],
security_opt=['seccomp=unconfined', 'apparmor=unconfined'],
ipc_mode='host', ulimits=[], privileged=False,
),
'retroarch': dict(
name='WolfRetroArch', title='RetroArch',
icon='https://games-on-whales.github.io/wildlife/apps/retroarch/assets/icon.png',
image='ghcr.io/games-on-whales/retroarch:edge',
mounts=[f'{games}/roms:/ROMs:rw',
f'{games}/saves:/mnt/games/saves:rw',
f'{games}/bios:/home/retro/bioses:rw',
f'{games}/retro-home:/home/retro/.config:rw',
f'{games}/retro-home-data:/home/retro/.local/share:rw',
f'{games}/retroarch:/home/retro/.config/retroarch:rw'],
env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host',
ulimits=[], privileged=False,
),
'prismlauncher': dict(
name='WolfPrismLauncher', title='Prism Launcher',
icon='https://games-on-whales.github.io/wildlife/apps/prismlauncher/assets/icon.png',
image='ghcr.io/games-on-whales/prismlauncher:edge',
mounts=[f'{games}/minecraft:/mnt/games/minecraft:rw'],
env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host',
ulimits=[], privileged=False,
),
'kodi': dict(
name='WolfKodi', title='Kodi',
icon='https://games-on-whales.github.io/wildlife/apps/kodi/assets/icon.png',
image='ghcr.io/games-on-whales/kodi:edge',
mounts=[f'{games}/kodi:/mnt/games/kodi:rw'],
env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host',
ulimits=[], privileged=False,
),
'firefox': dict(
name='WolfFirefox', title='Firefox',
icon='https://games-on-whales.github.io/wildlife/apps/firefox/assets/icon.png',
image='ghcr.io/games-on-whales/firefox:edge',
mounts=[f'{games}/firefox:/mnt/games/firefox:rw'],
env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host',
ulimits=[], privileged=False,
),
'desktop': dict(
name='WolfDesktop', title='Desktop',
# NOT "desktop" — ghcr.io/games-on-whales/desktop never existed.
# Confirmed live: Wolf logged "[DOCKER] error 404 - No such image:
# ghcr.io/games-on-whales/desktop:edge" and silently dropped back to
# the Moonlight app list with no other indication of failure. The
# games-on-whales/gow repo's apps/ directory names this app "xfce",
# and ghcr.io/games-on-whales/xfce:edge is the real, currently
# published image (confirmed against the GHCR package's own tag
# list). The icon path uses the same "xfce" naming.
icon='https://games-on-whales.github.io/wildlife/apps/xfce/assets/icon.png',
image='ghcr.io/games-on-whales/xfce:edge',
# Shares the SAME persistent home (.config/.local/share) and
# emulators/ -> ~/Applications mount as esde/retroarch below — a real
# XFCE multi-window session is a much more reliable place to run a
# standalone emulator's own GUI (Settings/Input dialogs) than inside
# ES-DE's single-app Sway kiosk session, where a second top-level
# window (e.g. Cemu's own Settings dialog) can fail to ever get
# mapped/focused. Whatever gets configured here (Cemu's settings.xml,
# controllerProfiles/, etc.) is read by the exact same app the next
# time it's launched through ES-DE, since it's the same mounted home.
mounts=[f'{games}/retro-home:/home/retro/.config:rw',
f'{games}/retro-home-data:/home/retro/.local/share:rw',
f'{games}/emulators:/mnt/games/emulators:rw',
f'{games}/emulators:/home/retro/Applications:rw',
# Same /ROMs path es-de/retroarch use — confirmed live: without
# this, a standalone emulator (Cemu) launched from this XFCE
# session via its own File/Load menu has nothing to browse to
# at all, since /ROMs simply doesn't exist in the container.
f'{games}/roms:/ROMs:rw',
f'{games}/saves:/mnt/games/saves:rw'],
# ghcr.io/games-on-whales/xfce has no libfuse2/libfuse3 at all (checked
# against its own Dockerfile) and, unlike es-de's own Dockerfile
# (which sets this exact env var for the same reason), no fallback
# either — confirmed live: launching an AppImage (Cemu) straight from
# this container failed outright with the standard "AppImages require
# FUSE to run" error. APPIMAGE_EXTRACT_AND_RUN=1 makes every AppImage
# self-extract into a temp dir and run from there instead of trying
# to FUSE-mount itself, matching what already makes AppImages work
# fine when ES-DE launches them.
env=STD_ENV + ['APPIMAGE_EXTRACT_AND_RUN=1'], cap_add=STD_CAP, security_opt=[], ipc_mode='host',
ulimits=[], privileged=False,
),
}
def ensure_tz(env_list):
# Set the container timezone so Steam / EA App / games show local time
# (a wrong/UTC display can confuse EA App's installer). Driven by WOLF_TZ.
e = [x for x in env_list if not x.startswith('TZ=')]
if TZ:
e.append(f'TZ={TZ}')
return e
def make_app_block(app):
host_cfg = {'IpcMode': app['ipc_mode'], 'CapAdd': app['cap_add'],
'Privileged': app['privileged'], 'DeviceCgroupRules': STD_RULES}
if app['security_opt']: host_cfg['SecurityOpt'] = app['security_opt']
if app['ulimits']: host_cfg['Ulimits'] = app['ulimits']
create_json = json.dumps({'HostConfig': host_cfg}, indent=2)
mounts_str = str(app['mounts']).replace('"', "'")
env_str = str(ensure_tz(app['env'])).replace('"', "'")
devices_str = str(app.get('devices', []))
return (
f"\n"
f" [[profiles.apps]]\n"
f" icon_png_path = '{app['icon']}'\n"
f" start_virtual_compositor = true\n"
f" title = '{app['title']}'\n"
f"\n"
f" [profiles.apps.runner]\n"
f" base_create_json = '''{create_json}\n"
f"'''\n"
f" devices = {devices_str}\n"
f" env = {env_str}\n"
f" image = '{app['image']}'\n"
f" mounts = {mounts_str}\n"
f" name = '{app['name']}'\n"
f" ports = []\n"
f" type = 'docker'\n"
)
def update_field(lines, wolf_name, field, new_value):
# Wolf rewrites config.toml and reformats arrays (mounts/env) as MULTI-LINE,
# so we must replace the entire array (from `<field> =` to its closing `]`),
# not just the first line — otherwise the old elements are left orphaned and
# the file becomes invalid TOML. Always rewrite as a single line, and heal
# any orphaned remnants left by a previously corrupted single-line rewrite.
for i, line in enumerate(lines):
if f"name = '{wolf_name}'" in line:
# Bound the search to just THIS app's own [[profiles.apps]] block —
# a blind +/-25-line window used to reach into a neighboring app's
# block once blocks got short enough (e.g. after collapsing a
# mounts array down to one line), splicing that block's own
# mounts/env field or table header instead. Confirmed live: this
# corrupted config.toml into invalid TOML ("cannot redefine
# existing table 'profiles.apps.runner'") and crash-looped Wolf.
block_start = i
while block_start > 0 and lines[block_start].strip() != '[[profiles.apps]]':
block_start -= 1
block_end = i + 1
while block_end < len(lines) and lines[block_end].strip() not in ('[[profiles.apps]]', '[[profiles]]'):
block_end += 1
for j in range(block_start, block_end):
if lines[j].lstrip().startswith(field + ' ='):
indent = lines[j][:len(lines[j]) - len(lines[j].lstrip())]
k = j
while k < block_end and ']' not in lines[k]:
k += 1
m = k + 1
while m < block_end:
s = lines[m].lstrip()
if s.startswith("'") or s.startswith(']'):
m += 1
else:
break
lines[j:m] = [f"{indent}{field} = {new_value}\n"]
return True
return False
def update_scalar_field(lines, wolf_name, field, new_value):
# Same block-bounding as update_field, but for a single-line scalar
# string field (image, icon_png_path) that Wolf never reformats across
# multiple lines. update_field's array logic (which scans forward
# hunting for a closing ']') isn't safe to reuse here — a scalar line
# has no ']' of its own and that scan would run off into an unrelated
# array field further down the same block (e.g. 'ports = []'),
# corrupting it. Confirmed needed live: a stale 'image' field (e.g. the
# games-on-whales/desktop -> xfce rename) was NOT refreshed by
# update_field, since only 'mounts'/'env' were ever passed to it — an
# already-installed app's broken image reference survived every
# reinstall/'./manage.sh apps' re-run until this was added.
for i, line in enumerate(lines):
if f"name = '{wolf_name}'" in line:
block_start = i
while block_start > 0 and lines[block_start].strip() != '[[profiles.apps]]':
block_start -= 1
block_end = i + 1
while block_end < len(lines) and lines[block_end].strip() not in ('[[profiles.apps]]', '[[profiles]]'):
block_end += 1
for j in range(block_start, block_end):
if lines[j].lstrip().startswith(field + " = '"):
indent = lines[j][:len(lines[j]) - len(lines[j].lstrip())]
lines[j] = f"{indent}{field} = '{new_value}'\n"
return True
return False
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]
added, updated, to_insert = [], [], []
for key in list(CATALOG.keys()):
if key not in selected: continue
app = CATALOG[key]
wolf_name = app['name']
new_mounts = str(app['mounts']).replace('"', "'")
new_env = str(ensure_tz(app['env'])).replace('"', "'")
new_devices = str(app.get('devices', []))
already = any(f"name = '{wolf_name}'" in l for l in first_block)
if already:
ok = update_field(lines, wolf_name, 'mounts', new_mounts)
ok = update_field(lines, wolf_name, 'env', new_env) or ok
# 'devices' is an array like mounts/env (even though it's currently
# always rendered on one line) — Wolf's own config.toml rewrites
# reformat arrays as multi-line, so this needs update_field's
# continuation-scanning logic, not update_scalar_field's single-line
# one, or a devices change (e.g. the esde entry's new /dev/uinput
# grant, added for AntiMicroX) would silently never apply on an
# "already installed" rerun — the same class of bug fixed for
# image/icon_png_path below by giving those their own scalar path.
ok = update_field(lines, wolf_name, 'devices', new_devices) or ok
ok = update_scalar_field(lines, wolf_name, 'image', app['image']) or ok
ok = update_scalar_field(lines, wolf_name, 'icon_png_path', app['icon']) or ok
if ok:
updated.append(app['title'])
else:
to_insert.append(make_app_block(app))
added.append(app['title'])
if to_insert:
block_lines = []
for block in to_insert:
block_lines += [l + '\n' if not l.endswith('\n') else l
for l in block.splitlines()]
new_lines = lines[:insert_at] + block_lines + lines[insert_at:]
with open(cfg, 'w') as f:
f.writelines(new_lines)
elif updated:
with open(cfg, 'w') as f:
f.writelines(lines)
if added: print(f"Added: {', '.join(added)}")
if updated: print(f"Updated mounts + timezone: {', '.join(updated)}")
if not added and not updated:
print('All selected apps already present and up to date')
PYEOF
docker compose restart wolf
echo "Wolf restarted. Apps will appear in Moonlight on next connection."
;;
cores)
# Download libretro (RetroArch) cores so retro games launch on first run.
# ES-DE / RetroArch ship with NO cores; both ES-DE's bundled config and
# GoW's rom_launcher.sh call ~/.config/retroarch/cores/*.so. Cores live on
# the game drive (retroarch/cores/) and are bind-mounted into the ES-DE and
# RetroArch containers at ~/.config/retroarch/cores.
#
# Usage: ./manage.sh cores [all|common] [force]
# all - every core on the libretro buildbot (~1.5 GB) [default],
# plus shaders/overlays/cheats/database/autoconfig/Dolphin's
# Sys folder (~290 MB more) — everything RetroArch's own
# Online Updater offers except thumbnails (separate, per-
# system, can run many GB — use the Thumbnails Updater)
# common - the ~30 cores GoW wires up + popular ES-DE defaults, no extras
# force - re-download cores/extras already present (otherwise skipped)
GAME_DIR=$(grep '^GAME_STORAGE_DIR=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2-)
if [ -z "$GAME_DIR" ]; then read -r -p " Game storage path: " GAME_DIR; fi
if [ -z "$GAME_DIR" ]; then echo "No game storage path."; exit 1; fi
CORES_DIR="$GAME_DIR/retroarch/cores"
SCOPE="${2:-all}"
FORCE="${3:-}"
BASE="https://buildbot.libretro.com/nightly/linux/x86_64/latest"
mkdir -p "$CORES_DIR"
# 'common' = the cores GoW's rom_launcher.sh wires up, plus a few very
# common ES-DE Linux defaults (NES/PCE/NDS/C64/ZX Spectrum).
COMMON="opera mame puae stella a5200 prosystem virtualjaguar mednafen_lynx \
flycast gambatte mgba picodrive genesis_plus_gx mupen64plus_next fbneo fceumm \
nestopia mednafen_ngp ppsspp pcsx_rearmed mednafen_psx_hw mednafen_saturn snes9x \
bsnes_hd_beta mednafen_vb mednafen_wswan mednafen_pce melonds desmume vice_x64 \
fuse scummvm"
if [ "$SCOPE" = "common" ]; then
CORE_FILES=""
for c in $COMMON; do CORE_FILES="$CORE_FILES ${c}_libretro.so"; done
else
echo "Fetching full core list from libretro buildbot..."
# The directory autoindex and the .index text file both contain the
# literal <core>_libretro.so.zip filenames, so this grep works on
# either. Fall back to the common set if the index can't be read.
CORE_FILES=$(curl -fsSL "$BASE/" \
| grep -oE '[A-Za-z0-9_]+_libretro\.so\.zip' \
| sed 's/\.zip$//' | sort -u)
if [ -z "$CORE_FILES" ]; then
echo "Could not read the full core index — falling back to the common set."
CORE_FILES=""
for c in $COMMON; do CORE_FILES="$CORE_FILES ${c}_libretro.so"; done
fi
fi
_unzip_to() { # $1=zip $2=destdir (unzip if present, else python3)
if command -v unzip >/dev/null 2>&1; then
unzip -o -q "$1" -d "$2"
else
python3 -c "import zipfile,sys; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])" "$1" "$2"
fi
}
total=$(echo $CORE_FILES | wc -w); n=0; ok=0; skip=0; fail=0
for so in $CORE_FILES; do
n=$((n+1))
if [ -f "$CORES_DIR/$so" ] && [ "$FORCE" != "force" ]; then
skip=$((skip+1)); continue
fi
printf "\r [%d/%d] %-34s" "$n" "$total" "$so"
tmp=$(mktemp)
if curl -fsSL -o "$tmp" "$BASE/$so.zip" && _unzip_to "$tmp" "$CORES_DIR" 2>/dev/null; then
ok=$((ok+1))
else
fail=$((fail+1))
fi
rm -f "$tmp"
done
echo ""
echo "RetroArch cores: $ok downloaded, $skip already present, $fail failed"
echo " → $CORES_DIR"
[ "$fail" -gt 0 ] && echo " Retry failed cores: ./manage.sh cores $SCOPE force"
# The Dolphin core needs its own 'Sys' folder (compatibility DB + IPL
# data) to boot Wii titles at all — it's not part of the core .so/.info
# pair above, and the only documented way to get it is a manual trip
# through RetroArch's own Online Updater -> Core System Files
# Downloader (Dolphin.zip). It's just a static folder from Dolphin's
# own repo (Data/Sys), so fetch it here too instead of requiring that
# GUI step. Sparse+shallow checkout — the full repo is large, this
# folder isn't.
#
# Confirmed live: RetroArch's own docs say this goes under
# system_directory/dolphin-emu/Sys, and GoW's own shipped
# retroarch.cfg sets system_directory = "~/bioses" — NOT RetroArch's
# usual default of ~/.config/retroarch/system. An earlier version of
# this script assumed the default and wrote to the wrong place
# (retroarch/system/dolphin-emu/Sys on the host, which the real
# config never reads). Read the actual configured value instead of
# assuming, and self-heal by moving a Sys folder that's already
# sitting in that old wrong location.
_RA_SYS_CFG=$(grep -i '^system_directory' "$GAME_DIR/retroarch/retroarch.cfg" 2>/dev/null \
| sed -E 's/^system_directory[[:space:]]*=[[:space:]]*"([^"]*)".*/\1/')
case "$_RA_SYS_CFG" in
"~/.config/retroarch/system") SYS_DIR="$GAME_DIR/retroarch/system/dolphin-emu/Sys" ;;
*) SYS_DIR="$GAME_DIR/bios/dolphin-emu/Sys" ;;
esac
OLD_SYS_DIR="$GAME_DIR/retroarch/system/dolphin-emu/Sys"
if [ "$OLD_SYS_DIR" != "$SYS_DIR" ] && [ -d "$OLD_SYS_DIR" ] && [ ! -d "$SYS_DIR" ]; then
mkdir -p "$(dirname "$SYS_DIR")"
mv "$OLD_SYS_DIR" "$SYS_DIR"
echo "Moved Dolphin's Sys folder to the location RetroArch's own config actually reads: $SYS_DIR"
fi
if [ -f "$CORES_DIR/dolphin_libretro.so" ] && [ ! -d "$SYS_DIR" ]; then
echo "Fetching Dolphin's Sys folder (needed for GameCube/Wii to boot)..."
SYS_TMP=$(mktemp -d)
if git clone --depth 1 --filter=blob:none --sparse -q \
https://github.com/dolphin-emu/dolphin "$SYS_TMP" 2>/dev/null \
&& (cd "$SYS_TMP" && git sparse-checkout set Data/Sys -q 2>/dev/null) \
&& [ -d "$SYS_TMP/Data/Sys" ]; then
mkdir -p "$(dirname "$SYS_DIR")"
cp -r "$SYS_TMP/Data/Sys" "$SYS_DIR"
echo "Dolphin Sys folder installed → $SYS_DIR"
else
echo "Could not fetch Dolphin's Sys folder automatically."
echo " Get it via RetroArch's own Online Updater -> Core System Files Downloader -> Dolphin.zip"
fi
rm -rf "$SYS_TMP"
fi
# 'all' also grabs the rest of what RetroArch's own Online Updater
# offers — shaders, overlays, cheats, the RDB game database, and
# controller autoconfig profiles — straight from the same buildbot
# server the cores above came from (buildbot.libretro.com/assets/
# frontend/), so a fresh install needs zero manual trips through that
# menu. Small (~290 MB total, confirmed against the real directory
# listing) next to the ~1.5 GB core set, and open/redistributable
# libretro-project content — nothing license-gated. Thumbnails
# (box art) are deliberately NOT included here: they're hosted
# separately, are per-system, and can run into many GB — pull those
# per-system from RetroArch's own Thumbnails Updater instead of
# blindly grabbing everything.
if [ "$SCOPE" = "all" ]; then
ASSETS_BASE="https://buildbot.libretro.com/assets/frontend"
RA_CFG_DIR="$GAME_DIR/retroarch"
# pack name → destination dir, using RetroArch's own default
# paths (relative to the config dir) for cheats/database/
# autoconfig, since this box's retroarch.cfg leaves those three
# unset and just inherits the defaults.
for pair in "overlays:$RA_CFG_DIR/overlays" \
"shaders_slang:$RA_CFG_DIR/shaders" \
"cheats:$RA_CFG_DIR/cheats" \
"database-rdb:$RA_CFG_DIR/database/rdb" \
"autoconfig:$RA_CFG_DIR/autoconfig" \
"info:$CORES_DIR"; do
pack="${pair%%:*}"; dest="${pair#*:}"
# "info" shares $CORES_DIR with the core .so files downloaded
# above, so "is the destination non-empty" is always true
# there and would skip this pack every time (confirmed
# live: exactly this made every .info file silently never
# install, breaking content-database extension matching for
# every core). Check for its own actual content instead.
if [ "$pack" = "info" ]; then
[ -n "$(ls "$dest"/*.info 2>/dev/null)" ] && [ "$FORCE" != "force" ] && continue
else
[ -d "$dest" ] && [ "$(ls -A "$dest" 2>/dev/null)" ] && [ "$FORCE" != "force" ] && continue
fi
echo "Fetching $pack.zip..."
tmp=$(mktemp)
if curl -fsSL -o "$tmp" "$ASSETS_BASE/$pack.zip"; then
mkdir -p "$dest"
_unzip_to "$tmp" "$dest" 2>/dev/null || echo " Failed to extract $pack.zip"
else
echo " Failed to fetch $pack.zip"
fi
rm -f "$tmp"
done
echo "Shaders, overlays, cheats, database, and controller autoconfig profiles ready."
fi
;;
reorder)
# Interactively reorder the Moonlight tiles by reordering the
# [[profiles.apps]] blocks in Wolf's config.toml (Moonlight shows them
# in file order). config.toml is root-owned, so run Python under sudo;
# the prompt is read from /dev/tty since stdin is the heredoc script.
if [ ! -f "$WOLF_CFG" ]; then
echo "Wolf config not found at $WOLF_CFG — start Wolf once first."
exit 1
fi
sudo python3 - "$WOLF_CFG" << 'PYEOF'
import sys, re
cfg = sys.argv[1]
with open(cfg) as f:
lines = f.readlines()
prof = [i for i, l in enumerate(lines) if l.strip() == '[[profiles]]']
if not prof:
print('No [[profiles]] section found.'); sys.exit(1)
start = prof[0]
end = prof[1] if len(prof) > 1 else len(lines)
marks = [i for i in range(start, end) if lines[i].strip() == '[[profiles.apps]]']
if len(marks) < 2:
print('Fewer than two apps — nothing to reorder.'); sys.exit(2)
head = lines[:start]
preamble = lines[start:marks[0]]
tail = lines[end:]
blocks = [lines[s:(marks[k+1] if k+1 < len(marks) else end)]
for k, s in enumerate(marks)]
def title_of(b):
for l in b:
m = re.match(r"\s*title = '(.*)'\s*$", l)
if m:
return m.group(1)
return '(untitled)'
titles = [title_of(b) for b in blocks]
print('\nCurrent Moonlight order:')
for i, t in enumerate(titles, 1):
print(' %d. %s' % (i, t))
print('\nType the new order as space-separated numbers (each once).')
print('Example: 3 1 2%s — blank line cancels.'
% (''.join(' %d' % n for n in range(4, len(blocks) + 1))))
tty = open('/dev/tty')
sys.stdout.write('New order: '); sys.stdout.flush()
resp = tty.readline().strip()
if not resp:
print('Cancelled — nothing changed.'); sys.exit(2)
try:
order = [int(x) for x in resp.split()]
except ValueError:
print('Invalid input — expected numbers.'); sys.exit(2)
if sorted(order) != list(range(1, len(blocks) + 1)):
print('Must list each number 1..%d exactly once.' % len(blocks)); sys.exit(2)
out = head + preamble
for n in order:
out += blocks[n - 1]
out += tail
with open(cfg, 'w') as f:
f.writelines(out)
print('\nNew order: ' + ' -> '.join(titles[n - 1] for n in order))
sys.exit(0)
PYEOF
if [ $? -eq 0 ]; then
docker compose restart wolf
echo "Wolf restarted — the new tile order shows on next Moonlight connection."
fi
;;
add-web)
# Add a Moonlight tile that opens a URL in a Firefox kiosk streaming container.
# Usage: ./manage.sh add-web [name] [url]
if [ ! -f "$WOLF_CFG" ]; then
echo "Wolf config not found at $WOLF_CFG — start Wolf once first."
exit 1
fi
# Gather display name
WEB_TITLE="${2:-}"
if [ -z "$WEB_TITLE" ]; then
read -r -p " Display name (e.g. 'Dinosaur Game'): " WEB_TITLE
fi
WEB_TITLE="${WEB_TITLE:-Web App}"
# Gather URL
WEB_URL="${3:-}"
if [ -z "$WEB_URL" ]; then
read -r -p " URL (e.g. https://dinosaur-game.io/): " WEB_URL
fi
if [ -z "$WEB_URL" ]; then echo "No URL entered."; exit 1; fi
# Prepend https:// if no scheme given
[[ "$WEB_URL" =~ ^https?:// ]] || WEB_URL="https://$WEB_URL"
# Try to auto-detect an icon
WEB_DOMAIN=$(python3 -c "from urllib.parse import urlparse; u=urlparse('$WEB_URL'); print(u.scheme+'://'+u.netloc)" 2>/dev/null)
WEB_ICON=""
echo " Looking for icon at $WEB_DOMAIN..."
for _try_icon in \
"$WEB_DOMAIN/apple-touch-icon.png" \
"$WEB_DOMAIN/apple-touch-icon-precomposed.png" \
"$WEB_DOMAIN/icon.png" \
"$WEB_DOMAIN/favicon.png"; do
if curl -fsSL --max-time 5 -o /dev/null -w "%{http_code}" "$_try_icon" 2>/dev/null \
| grep -q "^2"; then
WEB_ICON="$_try_icon"
echo " Found icon: $WEB_ICON"
break
fi
done
if [ -z "$WEB_ICON" ]; then
echo " Could not auto-detect a PNG icon."
echo " Options:"
echo " 1) Enter an image URL (PNG)"
echo " 2) Enter a local PNG file path"
echo " 3) Use default browser icon"
read -r -p " Choice [3]: " _ICON_CHOICE
_ICON_CHOICE="${_ICON_CHOICE:-3}"
if [ "$_ICON_CHOICE" = "1" ]; then
read -r -p " Image URL: " WEB_ICON
elif [ "$_ICON_CHOICE" = "2" ]; then
read -r -p " Local PNG path: " _LOCAL_PNG
_LOCAL_PNG="${_LOCAL_PNG/#\~/$HOME}"
if [ -f "$_LOCAL_PNG" ]; then
# Copy to wolf state dir so Wolf can find it
_ICONS_DIR="${WOLF_STATE_DIR:-/etc/wolf}/cfg/icons"
sudo mkdir -p "$_ICONS_DIR"
_ICON_NAME="$(basename "$_LOCAL_PNG")"
sudo cp "$_LOCAL_PNG" "$_ICONS_DIR/$_ICON_NAME"
WEB_ICON="$_ICONS_DIR/$_ICON_NAME"
else
echo " File not found — using default icon."
fi
fi
fi
[ -z "$WEB_ICON" ] && \
WEB_ICON="https://games-on-whales.github.io/wildlife/apps/firefox/assets/icon.png"
# Sanitize title → WolfWeb-<CamelCase>
WOLF_WEB_NAME="WolfWeb-$(echo "$WEB_TITLE" | tr -s ' \t' '-' | tr -cd 'A-Za-z0-9-')"
# The GoW firefox image launches `firefox` with no URL and honors no
# START_URL env var — it reads a Firefox enterprise policy file at
# /etc/firefox/policies/policies.json. Generate a per-app policy that
# sets the homepage to our URL and mount it into the container so the
# tile opens that site on launch instead of a blank default tab.
WEBAPP_DIR="${WOLF_STATE_DIR:-/etc/wolf}/cfg/webapps/$WOLF_WEB_NAME"
sudo mkdir -p "$WEBAPP_DIR"
sudo tee "$WEBAPP_DIR/policies.json" >/dev/null << JSON
{
"policies": {
"Homepage": { "URL": "$WEB_URL", "StartPage": "homepage", "Locked": false },
"OverrideFirstRunPage": "",
"OverridePostUpdatePage": "",
"DisableAppUpdate": true,
"DisableTelemetry": true,
"Preferences": {
"gfx.webrender.all": { "Value": true, "Status": "default" },
"webgl.force-enabled": { "Value": true, "Status": "default" }
}
}
}
JSON
echo ""
echo " Adding Moonlight tile:"
echo " Title : $WEB_TITLE"
echo " URL : $WEB_URL"
echo " Icon : $WEB_ICON"
echo " Name : $WOLF_WEB_NAME"
echo ""
sudo python3 - "$WOLF_CFG" "$WOLF_WEB_NAME" "$WEB_TITLE" "$WEB_ICON" "$WEBAPP_DIR/policies.json" << 'PYEOF'
import sys, json, re
cfg = sys.argv[1]
wolf_name = sys.argv[2]
title = sys.argv[3]
icon = sys.argv[4]
policies = sys.argv[5] # host path to the per-app firefox policies.json
STD_RULES = ['c 13:* rmw', 'c 244:* rmw']
cap_add = ['NET_RAW', 'MKNOD', 'NET_ADMIN']
env = [
'MOZ_ENABLE_WAYLAND=1',
'RUN_SWAY=1',
'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*',
]
# Mount our policy file over the image's default so Firefox opens the homepage.
mounts = [f'{policies}:/etc/firefox/policies/policies.json:ro']
host_cfg = {
'IpcMode': 'host',
'CapAdd': cap_add,
'Privileged': False,
'DeviceCgroupRules': STD_RULES,
}
create_json = json.dumps({'HostConfig': host_cfg}, indent=2)
env_str = str(env).replace('"', "'")
mounts_str = str(mounts).replace('"', "'")
block = (
f"\n"
f" [[profiles.apps]]\n"
f" icon_png_path = '{icon}'\n"
f" start_virtual_compositor = true\n"
f" title = '{title}'\n"
f"\n"
f" [profiles.apps.runner]\n"
f" base_create_json = '''{create_json}\n"
f"'''\n"
f" devices = []\n"
f" env = {env_str}\n"
f" image = 'ghcr.io/games-on-whales/firefox:edge'\n"
f" mounts = {mounts_str}\n"
f" name = '{wolf_name}'\n"
f" ports = []\n"
f" type = 'docker'\n"
)
with open(cfg) as f:
lines = f.readlines()
# If a tile with this name already exists, remove its whole [[profiles.apps]]
# block first so re-running add-web replaces it (e.g. to fix the URL/mounts).
def remove_existing(lines, wolf_name):
name_idx = next((i for i, l in enumerate(lines)
if f"name = '{wolf_name}'" in l), None)
if name_idx is None:
return lines, False
# Start of this app block = nearest [[profiles.apps]] at/above the name line
start = name_idx
while start > 0 and lines[start].strip() != '[[profiles.apps]]':
start -= 1
# End = next app/profiles marker after the name line, else EOF
end = name_idx + 1
while end < len(lines) and lines[end].strip() not in ('[[profiles.apps]]', '[[profiles]]'):
end += 1
del lines[start:end]
return lines, True
lines, replaced = remove_existing(lines, wolf_name)
# Find insert point: just before the second [[profiles]] block, or end of file
profiles_seen, insert_at = 0, len(lines)
for i, line in enumerate(lines):
if line.strip() == '[[profiles]]':
profiles_seen += 1
if profiles_seen == 2:
insert_at = i
break
block_lines = [l + '\n' if not l.endswith('\n') else l for l in block.splitlines()]
new_lines = lines[:insert_at] + block_lines + lines[insert_at:]
with open(cfg, 'w') as f:
f.writelines(new_lines)
action = 'Replaced' if replaced else 'Added'
print(f"{action} '{title}' ({wolf_name}) — tile updates in Moonlight after Wolf restarts.")
sys.exit(0)
PYEOF
if [ $? -eq 0 ]; then
docker compose restart wolf
echo "Wolf restarted — '$WEB_TITLE' will appear in Moonlight on next connection."
echo ""
echo " Controller tip: Wolf injects a virtual gamepad into the streaming container."
echo " Sites that use the browser Gamepad API (many classic arcade ports, etc.)"
echo " will respond to your controller. Keyboard-driven sites won't respond to"
echo " gamepad buttons directly, but Wolf's virtual keyboard lets you type."
fi
;;
backup)
echo "Set up backups with the modular system: sudo ./setup.sh backup"
;;
ge-proton)
# Install the latest GloriousEggroll Proton-GE build into Steam's
# compatibilitytools.d so it appears in each game's Compatibility
# dropdown. GE-Proton ships fixes (notably for the EA App installer)
# that stock Proton / Proton Experimental lack — required to get EA
# titles like Star Wars Battlefront II (2017) past their install script.
STEAM_HOME=$(find "${WOLF_STATE_DIR:-/etc/wolf}" -maxdepth 2 -type d -name Steam 2>/dev/null | head -1)
if [ -z "$STEAM_HOME" ]; then
echo "No Steam home found yet under ${WOLF_STATE_DIR:-/etc/wolf}."
echo "Launch Steam once from Moonlight, then re-run: ./manage.sh ge-proton"
exit 1
fi
# Steam scans ~/.steam/root/compatibilitytools.d, which resolves to
# <Steam home>/.steam/compatibilitytools.d on disk.
COMPAT="$STEAM_HOME/.steam/compatibilitytools.d"
# Check for a pre-downloaded cache (populated by wolf.sh at install time).
CACHE_DIR="$SCRIPT_DIR/ge-proton-cache"
CACHED_VER=""
[ -f "$CACHE_DIR/.version" ] && CACHED_VER=$(cat "$CACHE_DIR/.version")
if [ -n "$2" ]; then
# Explicit version requested — use cache if it matches, else download.
NAME="$2"
CACHED_FILE="$CACHE_DIR/$NAME.tar.gz"
elif [ -n "$CACHED_VER" ] && [ -f "$CACHE_DIR/$CACHED_VER.tar.gz" ]; then
NAME="$CACHED_VER"
CACHED_FILE="$CACHE_DIR/$NAME.tar.gz"
echo "Using pre-downloaded GE-Proton: $NAME"
else
echo "Fetching latest GE-Proton release info..."
URL=$(curl -sL https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases/latest \
| grep browser_download_url | grep '\.tar\.gz' | cut -d'"' -f4)
if [ -z "$URL" ]; then
echo "Could not determine GE-Proton download URL (GitHub rate-limited or offline?)."
exit 1
fi
NAME=$(basename "$URL" .tar.gz)
CACHED_FILE=""
fi
if [ -d "$COMPAT/$NAME" ]; then
echo "$NAME already installed."
else
sudo mkdir -p "$COMPAT"
if [ -n "$CACHED_FILE" ] && [ -f "$CACHED_FILE" ]; then
echo "Extracting $NAME from cache..."
sudo tar -xzf "$CACHED_FILE" -C "$COMPAT"
else
URL="${URL:-https://github.com/GloriousEggroll/proton-ge-custom/releases/download/$NAME/$NAME.tar.gz}"
echo "Downloading $NAME (~500 MB)..."
TMP=$(mktemp -d)
if ! curl -L -o "$TMP/ge.tar.gz" "$URL"; then
echo "Download failed."; rm -rf "$TMP"; exit 1
fi
sudo tar -xzf "$TMP/ge.tar.gz" -C "$COMPAT"
rm -rf "$TMP"
fi
fi
# Wolf's app containers run as uid 1000 (retro). The compat tool must be
# owned by that user or Wine refuses to use it / Steam can't launch it.
sudo chown -R 1000:1000 "$COMPAT"
echo ""
echo "Installed: $COMPAT/$NAME"
echo "Now fully quit and reopen Steam in Moonlight, then per game:"
echo " Properties → Compatibility → Force the use of $NAME"
;;
games)
# List installed Steam games (numbered) with their AppIDs, then offer to
# apply the EA App install-script fix to whichever one you pick. Read
# straight from the Steam app manifests.
STEAM_HOME=$(_steam_home)
APPSDIR="$STEAM_HOME/.steam/steam/steamapps"
if [ ! -d "$APPSDIR" ]; then
echo "No Steam library found yet. Launch Steam and install a game first."
exit 1
fi
shopt -s nullglob
ids=(); names=(); n=0
for acf in "$APPSDIR"/appmanifest_*.acf; do
id=$(grep -oP '"appid"\s*"\K[0-9]+' "$acf" | head -1)
name=$(grep -oP '"name"\s*"\K[^"]+' "$acf" | head -1)
[ -z "$id" ] && continue
n=$((n+1))
ids+=("$id"); names+=("$name")
done
if [ "$n" = 0 ]; then
echo " (no games installed yet)"
exit 0
fi
printf " %-4s %-10s %s\n" "#" "AppID" "Name"
printf " %-4s %-10s %s\n" "-" "-----" "----"
for i in $(seq 1 "$n"); do
printf " %-4s %-10s %s\n" "$i" "${ids[$((i-1))]}" "${names[$((i-1))]}"
done
echo ""
echo "An EA game stuck on 'running install script (EA app)'? Enter its"
echo "number to apply the EA fix, or just press Enter to skip."
read -r -p " Apply EA fix to # (or Enter to skip): " pick
if [ -z "$pick" ]; then
exit 0
fi
if ! [[ "$pick" =~ ^[0-9]+$ ]] || [ "$pick" -lt 1 ] || [ "$pick" -gt "$n" ]; then
echo "Not a valid number from the list."
exit 1
fi
sel_id="${ids[$((pick-1))]}"
sel_name="${names[$((pick-1))]}"
echo "Applying EA fix to '$sel_name' (AppID $sel_id)..."
_apply_ea_fix "$sel_id"
;;
fix-ea-game)
# Apply the EA App install-script fix directly to an AppID (defaults to
# Star Wars Battlefront II 2017, AppID 1237950). For an interactive
# picker use './manage.sh games' instead. Run AFTER setting the game to
# GE-Proton and clicking Play once (so the Proton prefix exists).
_apply_ea_fix "${2:-1237950}"
;;
wait-ea-app)
# Wait for EAappInstaller to finish installing EA App in the background
# (it runs async via RunType=1 in installScript.vdf), then apply the full
# EA fix including the link2ea:// protocol handler registration.
#
# Usage: ./manage.sh wait-ea-app [AppID]
#
# Flow:
# 1. Click Play on the game in Steam — SWBF2 will launch and quit after ~6s
# 2. EAappInstaller is now running in the background inside the container
# 3. Run this command — it polls every 15s for EADesktop.exe to appear
# 4. Once found, it stops the container, patches the registry, restarts
_wea_appid="${2:-1237950}"
_wea_steam_home=$(_steam_home)
_wea_pfx="$_wea_steam_home/.steam/steam/steamapps/compatdata/$_wea_appid/pfx/drive_c"
echo "Watching for EA App (EADesktop.exe) to appear in AppID $_wea_appid prefix..."
echo "If you haven't yet: go to Steam → click Play on the game now."
echo "Press Ctrl+C to abort."
_wea_found=0
for _wea_i in $(seq 1 80); do
_wea_exe=$(sudo find "$_wea_pfx" -maxdepth 6 \
-ipath "*/Electronic Arts/EA Desktop/EA Desktop/EADesktop.exe" \
-print -quit 2>/dev/null)
if [ -n "$_wea_exe" ]; then
_wea_found=1
echo ""
echo "EA App found at: $_wea_exe"
break
fi
printf "\r Waiting... (%ds elapsed)" "$((_wea_i * 15))"
sleep 15
done
if [ "$_wea_found" = 0 ]; then
echo ""
echo "EA App did not appear after 20 minutes. Things to check:"
echo " • Did you click Play in Steam while Wolf was running?"
echo " • Is the WolfSteam container still up? (./manage.sh status)"
echo " • Check container logs: docker logs \$(docker ps --format '{{.Names}}' | grep WolfSteam | head -1)"
exit 1
fi
echo "Applying full EA fix (includes link2ea:// handler)..."
_apply_ea_fix "$_wea_appid"
;;
install-ea-app)
# Run EAappInstaller.exe INSIDE the WolfSteam container using GE-Proton's
# wine binary so it sees a virtual display and can complete its GUI.
#
# Usage: ./manage.sh install-ea-app [AppID]
#
# Workflow:
# 1. Start Wolf and open Steam in Moonlight: ./manage.sh start
# 2. From a terminal/SSH: ./manage.sh install-ea-app
# 3. Watch Moonlight — the EA App installer window appears; click through it
# 4. Log in to your EA account when prompted
# 5. After EA App installs, run: ./manage.sh fix-ea-game
# 6. Click Play on the game — it should launch via EA App now
_ia_appid="${2:-1237950}"
_ia_steam_home=$(_steam_home)
if [ -z "$_ia_steam_home" ]; then
echo "No Steam home found. Start Wolf and open the Steam app in Moonlight first."
exit 1
fi
_ia_container=$(docker ps --format '{{.Names}}' | grep -i WolfSteam | head -1)
if [ -z "$_ia_container" ]; then
echo "WolfSteam container is not running."
echo " Start Wolf: ./manage.sh start"
echo " Open the Steam app in Moonlight, wait for Steam to load, then re-run."
exit 1
fi
# Find EAappInstaller bundled with the game (in steamapps/common, NOT in
# compatdata — ignore temp-extracted copies inside the Wine prefix).
_ia_installer_host=$(sudo find \
"$_ia_steam_home/.steam/steam/steamapps/common" -maxdepth 12 \
-name "EAappInstaller.exe" -print -quit 2>/dev/null)
if [ -z "$_ia_installer_host" ]; then
echo "EAappInstaller.exe not found under steamapps/common."
echo "Make sure the game (AppID $_ia_appid) is fully downloaded/installed."
exit 1
fi
# Container mounts the session home at /home/retro — translate paths
_ia_installer_container="${_ia_installer_host/#$_ia_steam_home//home/retro}"
# Find GE-Proton inside the container
_ia_gep=$(docker exec -u 1000 "$_ia_container" bash -c \
'ls /home/retro/.steam/compatibilitytools.d/ 2>/dev/null | grep -i GE-Proton | sort -V | tail -1' 2>/dev/null)
if [ -z "$_ia_gep" ]; then
echo "GE-Proton not found inside the container."
echo " Install it: ./manage.sh ge-proton"
echo " Then open Steam in Moonlight, set the game to use GE-Proton in Compatibility,"
echo " click Play once to create the prefix, and re-run this command."
exit 1
fi
_ia_wine="/home/retro/.steam/compatibilitytools.d/$_ia_gep/files/bin/wine"
_ia_wineprefix="/home/retro/.steam/steam/steamapps/compatdata/$_ia_appid/pfx"
_ia_display=$(docker exec "$_ia_container" printenv DISPLAY 2>/dev/null)
_ia_display="${_ia_display:-:0}"
echo "Installing EA App inside the WolfSteam container..."
echo " Container: $_ia_container"
echo " GE-Proton: $_ia_gep"
echo " Installer: $_ia_installer_container"
echo " Display: $_ia_display"
echo ""
echo "IMPORTANT: The EA App installer window will appear in Moonlight."
echo " Click through it and log in to your EA account."
echo " This may take 2-10 minutes."
echo ""
# Remove the fake InstallSuccessful keys we added earlier with fix-ea-game.
# If those keys are present, EAappInstaller detects EA App is "already installed"
# and exits immediately without doing anything — this is why it exited in ~15s.
# The real EA App installer will re-create these keys correctly after install.
echo "Clearing fake InstallSuccessful registry markers (so the installer actually runs)..."
for _ia_key in \
"HKLM\\\\Software\\\\Electronic Arts\\\\EA Desktop" \
"HKLM\\\\Software\\\\Wow6432Node\\\\Electronic Arts\\\\EA Desktop"; do
docker exec -u 1000 \
-e DISPLAY="$_ia_display" \
-e WINEPREFIX="$_ia_wineprefix" \
"$_ia_container" \
"$_ia_wine" reg delete "$_ia_key" /v InstallSuccessful /f 2>/dev/null || true
done
sleep 1
# Start wineserver in foreground mode (-f) so it keeps running even after
# the WiX bootstrapper exits. Without this, wineserver shuts down when
# the foreground wine process exits and kills the real installer.
# wineserver -f stays alive until explicitly killed with wineserver -k.
_ia_wineserver="${_ia_wine%/wine}/wineserver"
echo "Starting wineserver anchor (keeps Wine alive while installer runs)..."
docker exec -d -u 1000 \
-e DISPLAY="$_ia_display" \
-e WINEPREFIX="$_ia_wineprefix" \
"$_ia_container" \
"$_ia_wineserver" -f
sleep 3
# Launch the installer detached — it will spawn background processes
# and exit, but those processes stay alive because the anchor above
# keeps the shared wineserver running.
echo "Launching EA App installer (watch Moonlight for the install window)..."
docker exec -d -u 1000 \
-e DISPLAY="$_ia_display" \
-e WINEPREFIX="$_ia_wineprefix" \
-e WINE_LARGE_ADDRESS_AWARE=1 \
"$_ia_container" \
"$_ia_wine" "$_ia_installer_container"
# Poll on the HOST for EADesktop.exe to appear (up to 15 min)
_ia_pfx_host="$_ia_steam_home/.steam/steam/steamapps/compatdata/$_ia_appid/pfx/drive_c"
_ia_found=0
for _ia_i in $(seq 1 60); do
_ia_exe=$(sudo find "$_ia_pfx_host" -maxdepth 8 \
-ipath "*/Electronic Arts/EA Desktop/EA Desktop/EADesktop.exe" \
-print -quit 2>/dev/null)
if [ -n "$_ia_exe" ]; then
_ia_found=1
echo ""
echo "EA App installed at: $_ia_exe"
break
fi
printf "\r Waiting for EA App to install... (%ds elapsed)" "$((_ia_i * 15))"
sleep 15
done
# Kill the anchor (and any remaining wine processes for this prefix)
echo "Stopping wineserver anchor..."
docker exec -u 1000 \
-e WINEPREFIX="$_ia_wineprefix" \
"$_ia_container" \
"$_ia_wineserver" -k 2>/dev/null || true
if [ "$_ia_found" = 1 ]; then
echo "Applying EA fix (registering link2ea:// handler)..."
_apply_ea_fix "$_ia_appid"
echo ""
echo "Next: Click Play on the game in Steam. EA App should authenticate and launch it."
else
echo ""
echo "EA App did not finish installing within 15 minutes."
echo " • Did the installer window appear in Moonlight?"
echo " • Try running ./manage.sh diagnose-ea $_ia_appid for current state"
echo " • If the installer appeared but failed: check the EA App website for a newer installer"
exit 1
fi
;;
diagnose-ea)
# Show the current state of the EA App / link2ea:// setup for debugging.
# Run this after ./manage.sh fix-ea-game to verify the registry is correct,
# or when troubleshooting why an EA game returns to the Play screen.
#
# Usage: ./manage.sh diagnose-ea [AppID]
_dx_appid="${2:-1237950}"
_dx_sh=$(_steam_home)
_dx_pfx="$_dx_sh/.steam/steam/steamapps/compatdata/$_dx_appid/pfx"
_dx_acf="$_dx_sh/.steam/steam/steamapps/appmanifest_${_dx_appid}.acf"
echo "=== EA Diagnostic — AppID $_dx_appid ==="
echo "Prefix: $_dx_pfx"
echo ""
echo "--- system.reg: link2ea entries ---"
sudo grep -i -A3 "link2ea" "$_dx_pfx/system.reg" 2>/dev/null || echo "(none in system.reg)"
echo ""
echo "--- user.reg: link2ea entries ---"
sudo grep -i -A3 "link2ea" "$_dx_pfx/user.reg" 2>/dev/null || echo "(none in user.reg)"
echo ""
echo "--- EA App executables in prefix ---"
_dx_desktop=$(sudo find "$_dx_pfx/drive_c" -maxdepth 8 -iname "EADesktop.exe" -print 2>/dev/null)
_dx_link2ea=$(sudo find "$_dx_pfx/drive_c" -maxdepth 8 -iname "Link2EA.exe" -print 2>/dev/null)
echo "EADesktop.exe: ${_dx_desktop:-(not found)}"
echo "Link2EA.exe: ${_dx_link2ea:-(not found)}"
echo ""
echo "--- system.reg: InstallSuccessful ---"
sudo grep -i "InstallSuccessful" "$_dx_pfx/system.reg" 2>/dev/null | head -5 \
|| echo "(none)"
echo ""
echo "--- AppManifest StateFlags ---"
if [ -f "$_dx_acf" ]; then
grep "StateFlags" "$_dx_acf"
else
echo "(acf not found at $_dx_acf)"
fi
echo ""
echo "--- Recent Proton log entries (errors + EA-related) ---"
_dx_log="$_dx_sh/steam-${_dx_appid}.log"
if [ -f "$_dx_log" ]; then
grep -i "link2ea\|EA Desktop\|run_process\|ShellExecute\|err:" "$_dx_log" 2>/dev/null \
| grep -v "mscoree\|seh_unwind\|loaddll\|fixme" | tail -20
else
echo "(no log yet — launch the game once to generate it)"
echo "Expected path: $_dx_log"
fi
;;
setup-swbf2)
# Full automated setup for Star Wars Battlefront II (2017, AppID 1237950)
# on Wolf/Moonlight with GE-Proton. Implements the msiextract bypass for
# the JunoConfigureRegistry Wine incompatibility, installs EA Desktop files
# into the Wine prefix, and wires up a launch wrapper so registry fixes
# survive wineserver restarts.
#
# Prerequisites (do these first, then run this command):
# 1. Wolf is running and Steam is open in Moonlight
# 2. GE-Proton10-34+ is installed (./manage.sh ge-proton)
# 3. SWBF2 (AppID 1237950) is set to use GE-Proton in Steam → Compatibility
# 4. msitools is installed on the host (sudo apt-get install -y msitools)
# 5. Your EA account is linked to your Steam account at ea.com (one-time manual step)
# 6. Launch SWBF2 once from Moonlight, wait ~10s for the "Origin is not installed"
# error, then close it — this triggers Wine prefix creation and drops ea_app.msi
#
# After this command completes: launch SWBF2 from Moonlight. Let Vulkan
# shaders compile on first run (takes several minutes, only once). EA App
# authenticates via your linked Steam/EA account and the game launches.
#
# Usage: ./manage.sh setup-swbf2
_sw_appid="1237950"
_sw_sh=$(_steam_home)
if [ -z "$_sw_sh" ]; then
echo "Steam home not found. Start Wolf (./manage.sh start) and open Steam in Moonlight first."
exit 1
fi
_sw_container=$(docker ps --format '{{.Names}}' | grep -i WolfSteam | head -1)
if [ -z "$_sw_container" ]; then
echo "WolfSteam container is not running. Start Wolf: ./manage.sh start"
exit 1
fi
_sw_pfxc="$_sw_sh/.steam/steam/steamapps/compatdata/$_sw_appid/pfx/drive_c"
_sw_msi="$_sw_pfxc/ea_app.msi"
echo "=== SWBF2 (2017) Wolf/Moonlight Setup ==="
echo ""
# ── Step 1: verify Wine prefix exists ──────────────────────────────────
if [ ! -d "$_sw_pfxc" ]; then
echo "ERROR: Wine prefix not found at $_sw_pfxc"
echo ""
echo "Required setup before running this command:"
echo " 1. In Steam (via Moonlight): right-click SWBF2 → Properties → Compatibility"
echo " → Force GE-Proton10-34 (or later)"
echo " 2. Click Play on SWBF2 — wait ~10 seconds for 'Origin is not installed' error"
echo " 3. Close the error and return here"
exit 1
fi
echo "[1/6] Wine prefix found: OK"
# ── Step 2: check ea_app.msi exists ────────────────────────────────────
if [ ! -f "$_sw_msi" ]; then
echo ""
echo "ERROR: ea_app.msi not found at $_sw_msi"
echo ""
echo "SWBF2 must be launched once so Steam drops ea_app.msi into the Wine prefix."
echo " 1. Open Steam in Moonlight"
echo " 2. Click Play on SWBF2"
echo " 3. Wait ~10 seconds — you will see an 'Origin is not installed' error"
echo " 4. Close the error popup"
echo " 5. Re-run: ./manage.sh setup-swbf2"
echo ""
echo "Expected file size: ~227 MB"
exit 1
fi
_sw_msi_size=$(stat -c%s "$_sw_msi" 2>/dev/null || echo 0)
if [ "$_sw_msi_size" -lt 50000000 ]; then
echo "WARNING: ea_app.msi looks too small ($_sw_msi_size bytes, expected ~227 MB)."
echo "It may still be copying. Try again in a moment."
exit 1
fi
echo "[2/6] ea_app.msi found ($(( _sw_msi_size / 1048576 )) MB): OK"
# ── Step 3: extract MSI on the host (bypasses JunoConfigureRegistry) ──
echo "[3/6] Extracting EA Desktop files from MSI (this takes ~30s)..."
if ! command -v msiextract >/dev/null 2>&1; then
echo "ERROR: msitools not installed. Run: sudo apt-get install -y msitools"
exit 1
fi
_sw_extract_dir="/tmp/ea_app_extracted_$$"
rm -rf "$_sw_extract_dir"
mkdir -p "$_sw_extract_dir"
if ! msiextract -C "$_sw_extract_dir" "$_sw_msi" >/dev/null 2>&1; then
echo "ERROR: msiextract failed. Check that msitools is properly installed."
rm -rf "$_sw_extract_dir"
exit 1
fi
_sw_ea_src=$(find "$_sw_extract_dir" -maxdepth 4 \
-path "*/Electronic Arts/EA Desktop/EA Desktop" -type d 2>/dev/null | head -1)
if [ -z "$_sw_ea_src" ] || [ ! -f "$_sw_ea_src/Link2EA.exe" ]; then
echo "ERROR: Link2EA.exe not found after extraction. MSI structure may have changed."
echo "Expected: <extracted>/Electronic Arts/EA Desktop/EA Desktop/Link2EA.exe"
ls -la "$_sw_extract_dir/Electronic Arts/EA Desktop/" 2>/dev/null || true
rm -rf "$_sw_extract_dir"
exit 1
fi
echo " Extracted to: $_sw_ea_src"
echo " $(ls "$_sw_ea_src" | wc -l) files found including Link2EA.exe"
# ── Step 4: copy EA Desktop files into Wine prefix ─────────────────────
echo "[4/6] Installing EA Desktop files into Wine prefix..."
_sw_ea_dest_base="$_sw_pfxc/Program Files/Electronic Arts/EA Desktop"
# Determine the version directory name from the extracted content.
# The MSI extracts to "EA Desktop" but the real install uses a versioned dir
# with a symlink. Use the same version string if a broken symlink exists from
# a failed install attempt; otherwise default to 14.2.0.3345.
_sw_ea_version=$( \
docker exec "$_sw_container" \
readlink "/home/retro/.steam/steam/steamapps/compatdata/$_sw_appid/pfx/drive_c/Program Files/Electronic Arts/EA Desktop/EA Desktop" \
2>/dev/null || echo "14.2.0.3345")
_sw_ea_dest="$_sw_ea_dest_base/$_sw_ea_version"
sudo mkdir -p "$_sw_ea_dest"
sudo cp -r "$_sw_ea_src/." "$_sw_ea_dest/"
sudo chown -R 1000:1000 "$_sw_ea_dest_base"
# Remove any broken symlink, then create a clean one
sudo rm -f "$_sw_ea_dest_base/EA Desktop"
docker exec "$_sw_container" bash -c \
"ln -sf '$_sw_ea_version' '/home/retro/.steam/steam/steamapps/compatdata/$_sw_appid/pfx/drive_c/Program Files/Electronic Arts/EA Desktop/EA Desktop'" \
2>/dev/null || sudo ln -sf "$_sw_ea_version" "$_sw_ea_dest_base/EA Desktop"
if [ ! -f "$_sw_ea_dest/Link2EA.exe" ]; then
echo "ERROR: Copy failed — Link2EA.exe not found at $_sw_ea_dest/Link2EA.exe"
rm -rf "$_sw_extract_dir"
exit 1
fi
echo " EA Desktop installed to: $( echo "$_sw_ea_dest" | sed "s|$_sw_sh||" )"
rm -rf "$_sw_extract_dir"
# ── Step 5: write .reg files and wrapper script into Wine prefix ────────
echo "[5/6] Writing registry fix files and launch wrapper..."
_sw_drive_c="$_sw_pfxc"
# link2ea:// protocol handler — points Wine at Link2EA.exe
sudo tee "$_sw_drive_c/link2ea_fix.reg" > /dev/null << 'REGEOF'
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SOFTWARE\Classes\link2ea]
@="URL:link2ea Protocol"
"URL Protocol"=""
[HKEY_LOCAL_MACHINE\SOFTWARE\Classes\link2ea\shell\open\command]
@="\"C:\\Program Files\\Electronic Arts\\EA Desktop\\EA Desktop\\Link2EA.exe\" \"%1\""
REGEOF
# EA Desktop Windows services — EALocalHostSvc provides local IPC that
# Link2EA.exe requires; without it launch fails with RPC_S_SERVER_UNAVAILABLE
sudo tee "$_sw_drive_c/ea_services.reg" > /dev/null << 'REGEOF'
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\EALocalHostSvc]
"Type"=dword:00000010
"Start"=dword:00000002
"ErrorControl"=dword:00000001
"ImagePath"="C:\\Program Files\\Electronic Arts\\EA Desktop\\EA Desktop\\EALocalHostSvc.exe"
"DisplayName"="EA Local Host Service"
"ObjectName"="LocalSystem"
[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\EABackgroundService]
"Type"=dword:00000010
"Start"=dword:00000002
"ErrorControl"=dword:00000001
"ImagePath"="C:\\Program Files\\Electronic Arts\\EA Desktop\\EA Desktop\\EABackgroundService.exe"
"DisplayName"="EA Background Service"
"ObjectName"="LocalSystem"
REGEOF
sudo chown 1000:1000 "$_sw_drive_c/link2ea_fix.reg" "$_sw_drive_c/ea_services.reg"
# Launch wrapper — runs regedit via Steam's launch chain on every launch
# so registry entries survive wineserver restarts. Direct edits to
# system.reg are overwritten by wineserver when it flushes to disk.
sudo tee /tmp/ea_install_wrapper.sh > /dev/null << 'WRAPEOF'
#!/bin/bash
echo "=== launch $(date) ===" >> /tmp/ea_install.log
"$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" "${10}" "${11}" regedit /S "C:\\link2ea_fix.reg"
"$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" "${10}" "${11}" regedit /S "C:\\ea_services.reg"
exec "$@"
WRAPEOF
chmod +x /tmp/ea_install_wrapper.sh
docker cp /tmp/ea_install_wrapper.sh "$_sw_container":/home/retro/ea_install.sh
docker exec "$_sw_container" chmod +x /home/retro/ea_install.sh
rm -f /tmp/ea_install_wrapper.sh
echo " .reg files and wrapper script installed."
# ── Step 6: set LaunchOptions in localconfig.vdf and lock config dir ───
echo "[6/6] Setting Steam launch options and locking localconfig.vdf..."
_sw_steam_uid=$(sudo ls "$_sw_sh/.steam/steam/userdata/" 2>/dev/null | head -1)
if [ -z "$_sw_steam_uid" ]; then
echo "WARNING: No Steam userdata found. You may need to have logged into Steam first."
echo " Open Steam in Moonlight, sign in, then re-run this command."
else
_sw_cfg_dir="$_sw_sh/.steam/steam/userdata/$_sw_steam_uid/config"
_sw_cfg_file="$_sw_cfg_dir/localconfig.vdf"
if [ ! -f "$_sw_cfg_file" ]; then
echo "WARNING: localconfig.vdf not found at $_sw_cfg_file"
echo " Open Steam in Moonlight first, then re-run."
else
# Stop Steam so it flushes current state before we edit
docker exec "$_sw_container" pkill -f steam.sh 2>/dev/null || true
sleep 5
# Make sure config dir is writable long enough to edit the file
sudo chmod 755 "$_sw_cfg_dir" 2>/dev/null || true
_sw_launch_opt='STEAM_UNIX_SOCKET=/tmp/steam.sock /home/retro/ea_install.sh %command%'
sudo python3 - "$_sw_cfg_file" "$_sw_launch_opt" << 'PYEOF'
import sys, re
path, launch_opt = sys.argv[1], sys.argv[2]
with open(path, 'r') as f:
content = f.read()
new = re.sub(
r'("1237950".*?"LaunchOptions"\s*)"[^"]*"',
rf'\1"{launch_opt}"',
content, flags=re.DOTALL
)
if new == content:
new = re.sub(
r'("1237950"\s*\n\s*\{)',
rf'\1\n\t\t\t\t"LaunchOptions"\t\t"{launch_opt}"',
content
)
if new == content:
print("WARNING: 1237950 block not found in localconfig.vdf — launch options not set.")
print("In Steam: right-click SWBF2 → Properties → Launch Options, enter:")
print(" STEAM_UNIX_SOCKET=/tmp/steam.sock /home/retro/ea_install.sh %command%")
else:
with open(path, 'w') as f:
f.write(new)
print(" LaunchOptions set for AppID 1237950.")
PYEOF
sudo chown 1000:1000 "$_sw_cfg_file"
# Lock the config directory so Steam can't overwrite localconfig.vdf
# via atomic rename (temp file + rename). chmod 444 on the file is
# bypassed; locking the directory blocks the rename-into.
sudo chmod 555 "$_sw_cfg_dir"
echo " Config directory locked (chmod 555) to preserve launch options."
fi
fi
echo ""
echo "=== Setup complete ==="
echo ""
echo "Next steps:"
echo " 1. In Steam (Moonlight): right-click SWBF2 → Properties → Compatibility"
echo " → Force a specific Steam Play compatibility tool → GE-Proton10-34 (or later)"
echo " 2. Click Play on SWBF2"
echo " 3. Let Vulkan shaders compile — do NOT skip, takes several minutes, only happens once"
echo " 4. EA App will authenticate via your linked Steam/EA account (no manual login needed"
echo " if accounts are linked at ea.com)"
echo " 5. Game should launch"
echo ""
echo "Troubleshooting:"
echo " ./manage.sh diagnose-ea $_sw_appid - check registry state"
echo " docker exec \$container tail /tmp/ea_install.log - check wrapper log"
echo ""
echo "Note: If the config dir was not writable (Steam not stopped cleanly), set launch"
echo "options manually in Steam → SWBF2 → Properties → Launch Options:"
echo " STEAM_UNIX_SOCKET=/tmp/steam.sock /home/retro/ea_install.sh %command%"
;;
fix-perms)
# Wolf creates each app's home dir under WOLF_STATE_DIR/<session-id>/<App>
# and mounts it as /home/retro inside the app container. If anything in
# there ends up root-owned, the in-container 'retro' user (uid 1000)
# can't write to it and the app bails on startup with "Permission
# denied". Re-own the whole state folder + game storage to uid/gid 1000.
echo "Fixing ownership of Wolf state + game storage (uid 1000)..."
found=0
if [ -n "$WOLF_STATE_DIR" ] && [ -d "$WOLF_STATE_DIR" ]; then
sudo chown -R 1000:1000 "$WOLF_STATE_DIR"
echo " fixed: $WOLF_STATE_DIR"
found=1
fi
GAME_DIR=$(grep '^GAME_STORAGE_DIR=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2-)
if [ -n "$GAME_DIR" ] && [ -d "$GAME_DIR" ]; then
sudo chown -R 1000:1000 "$GAME_DIR"
echo " fixed: $GAME_DIR"
found=1
fi
[ "$found" = 0 ] && echo " Nothing to fix (no Wolf state dir found yet)."
echo "Done. Reconnect from Moonlight to relaunch the app."
;;
install-completion)
# Install bash tab-completion for this manage.sh so that double-tab
# after './manage.sh ' shows all available commands. Works for any
# user whose shell sources ~/.bash_completion.d/ (most modern setups
# do; Ubuntu 22.04+ sources it automatically via /etc/bash.bashrc).
COMP_DIR="$HOME/.bash_completion.d"
COMP_FILE="$COMP_DIR/manage-wolf"
mkdir -p "$COMP_DIR"
cat > "$COMP_FILE" << 'COMPEOF'
_manage_wolf_complete() {
local cur="${COMP_WORDS[COMP_CWORD]}"
local commands="start stop restart logs status pin controllers update apps cores reorder
add-web ge-proton games setup-swbf2 fix-ea-game wait-ea-app
install-ea-app diagnose-ea fix-perms install-completion backup"
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
}
# Register for both 'manage.sh' and './manage.sh' invocation styles
complete -F _manage_wolf_complete manage.sh
complete -F _manage_wolf_complete ./manage.sh
COMPEOF
# Also source it immediately in the current shell if possible
echo "source \"$COMP_FILE\"" >> "$HOME/.bash_completion" 2>/dev/null || true
echo "Bash completion installed → $COMP_FILE"
echo "Open a new terminal (or run: source \"$COMP_FILE\"), then:"
echo " ./manage.sh <TAB><TAB> shows all commands"
;;
controllers)
# Fixes a real, confirmed root cause — not a bug in Wolf or in any
# single emulator/game: every virtual gamepad Wolf creates of the
# SAME type (e.g. two Wii U Pro Controllers) gets the IDENTICAL SDL
# GUID, because an SDL GUID identifies a controller MODEL, not a
# physical instance — completely normal, expected behavior (two
# real identical physical controllers behave the exact same way).
# Some apps' own controller-picker UI just doesn't reliably tell
# apart two same-GUID devices though (confirmed with Cemu: the
# first controller ends up driving every player). Wolf's own
# per-client controllers_override setting (its docs' "Override the
# default joypad mapping" section) sidesteps this at the root:
# force each controller SLOT to a DIFFERENT pad type (e.g. slot 1 =
# Xbox, slot 2 = PlayStation) so their vendor/product IDs — and
# therefore their SDL GUIDs — genuinely differ. This isn't
# Cemu-specific: it fixes controller disambiguation the same way
# for every system/app that reads SDL joystick GUIDs.
#
# Needs Wolf's REST API socket, which is why the wolf service now
# sets WOLF_SOCKET_PATH and bind-mounts /var/run/wolf to the host
# (Wolf's own docs' recommended pattern for host access). Endpoint
# shapes below are confirmed against Wolf's real OpenAPI schema
# (docs/modules/dev/partials/spec.json in its own repo) — NOT the
# docs PAGE's own example curl command, which is stale: it shows
# "PATCH .../clients/<id>/settings", but the real, current endpoint
# is "POST /api/v1/clients/settings" with client_id in the JSON
# body, not the URL path.
#
# The socket is root-owned with mode 0755 (Docker auto-creates the
# host-side /var/run/wolf bind-mount source as root, and Wolf itself
# runs as root inside the container) — confirmed live: a plain user
# curl gets "Immediate connect fail ... Permission denied" (connecting
# to a UNIX socket needs write permission on the socket file itself,
# which group/other don't have here). sudo on both curl calls below
# is the fix, matching how every other root-owned Wolf state file
# ($WOLF_CFG) is already touched elsewhere in this same script (grep
# for "sudo python3 -").
SOCK="/var/run/wolf/wolf.sock"
if [ ! -S "$SOCK" ]; then
echo "Wolf's API socket isn't up at $SOCK yet."
echo " sudo ./setup.sh wolf # regenerates docker-compose.yml with the socket mount"
echo " docker compose up -d # recreates the wolf container so it takes effect"
exit 1
fi
CLIENTS_JSON=$(sudo curl -fsS --unix-socket "$SOCK" http://localhost/api/v1/clients 2>/dev/null)
if [ -z "$CLIENTS_JSON" ]; then
echo "Could not reach Wolf's API — is Wolf running? (./manage.sh status)"
exit 1
fi
# A bare client_id list is useless for telling devices apart — it's
# an opaque cert-derived number with nothing human-readable attached
# (confirmed against Wolf's own PairedClient API schema: no name, no
# IP, nothing). GET /api/v1/sessions DOES carry client_ip for every
# currently-streaming session though, so cross-referencing the two
# lets us tag whichever entry is actually connected right now with
# its real IP — the one piece of info a person can actually
# recognize ("oh, that's my gaming PC").
SESSIONS_JSON=$(sudo curl -fsS --unix-socket "$SOCK" http://localhost/api/v1/sessions 2>/dev/null)
# Wolf doesn't dedupe repeated pairings of the same device (confirmed
# live: re-pairing the same PC 6 times over produced 6 separate list
# entries, all with the identical client_id) — and Wolf's own
# get_client_by_id() (config.hpp) resolves by taking the FIRST match
# for a given id anyway, so every duplicate beyond the first is a
# dead ringer with no functional difference. Rather than force a
# choice between 6 identical-outcome options (confirmed live: this
# was a real, confusing thing to be asked to do), the picker only
# ever shows DISTINCT client_ids, first-occurrence order — matching
# what Wolf itself would actually resolve to regardless of which
# duplicate got picked.
CLIENT_IDS=()
while IFS= read -r cid; do
for existing in "${CLIENT_IDS[@]}"; do
[ "$existing" = "$cid" ] && continue 2
done
CLIENT_IDS+=("$cid")
done < <(echo "$CLIENTS_JSON" | python3 -c "
import json, sys
for c in json.load(sys.stdin)['clients']:
print(c['client_id'])
")
if [ "${#CLIENT_IDS[@]}" -eq 0 ]; then
echo "No paired Moonlight clients yet — pair one first (./manage.sh pin), then re-run this."
exit 1
fi
echo ""
echo "Paired devices (ACTIVE = streaming from this IP right now — the"
echo "reliable way to tell which entry is yours. If nothing shows"
echo "ACTIVE, start streaming from the device you want to configure,"
echo "leave it connected, and re-run this in another terminal):"
echo "$CLIENTS_JSON" | python3 -c "
import json, sys
d = json.load(sys.stdin)
try:
active = {s['client_id']: s['client_ip'] for s in json.loads(sys.argv[1]).get('sessions', []) if s.get('client_id')}
except Exception:
active = {}
seen = []
counts = {}
for c in d['clients']:
cid = c['client_id']
counts[cid] = counts.get(cid, 0) + 1
if cid not in seen:
seen.append(cid)
for i, cid in enumerate(seen):
first = next(c for c in d['clients'] if c['client_id'] == cid)
ov = first['settings'].get('controllers_override') or []
tag = 'ACTIVE - streaming from ' + active[cid] if cid in active else 'not currently connected'
dup = f\" (paired {counts[cid]}x)\" if counts[cid] > 1 else ''
print(f\" {i+1}) {cid}{dup} [{tag}]\")
print(f\" current override: {ov if ov else 'none - auto-detect'}\")
" "$SESSIONS_JSON"
ACTIVE_CLIENT_ID=$(python3 -c "
import json, sys
try:
sessions = json.loads(sys.argv[1]).get('sessions', [])
except Exception:
sessions = []
ids = {s['client_id'] for s in sessions if s.get('client_id')}
print(next(iter(ids)) if len(ids) == 1 else '')
" "$SESSIONS_JSON")
if [ -n "$ACTIVE_CLIENT_ID" ]; then
CLIENT_ID="$ACTIVE_CLIENT_ID"
echo ""
echo "Exactly one Moonlight session (device/TV) is actively streaming right"
echo "now — using that one. (This is about which SCREEN/DEVICE you're"
echo "configuring, not how many controllers are plugged into it — that's"
echo "the next question.)"
elif [ "${#CLIENT_IDS[@]}" -eq 1 ]; then
CLIENT_ID="${CLIENT_IDS[0]}"
echo ""
echo "Only one paired Moonlight device — using it."
else
echo ""
read -r -p "Which Moonlight session/device [1-${#CLIENT_IDS[@]}]: " PICK
if ! [[ "$PICK" =~ ^[0-9]+$ ]] || [ "$PICK" -lt 1 ] || [ "$PICK" -gt "${#CLIENT_IDS[@]}" ]; then
echo "Invalid selection."
exit 1
fi
CLIENT_ID="${CLIENT_IDS[$((PICK-1))]}"
fi
# Wolf's own logs record "Creating <TYPE> joypad for controller <N>"
# every time it creates a virtual pad (confirmed live against real
# log output) — polling this gives real, human-readable context per
# slot instead of asking the user to guess or watch logs by hand
# themselves. Wolf's own wording is 0-indexed ("controller 0",
# "controller 1"); every prompt in this tool is deliberately
# 1-indexed instead ("1st controller", "2nd controller"...) —
# confirmed live that mixing the two ("exactly one CLIENT is
# active" read as "exactly one CONTROLLER" right above a
# 0-indexed-sounding prompt) is a real, easy misread. "1st
# controller" here always means Wolf's "controller 0", "2nd" means
# "controller 1", and so on — stated explicitly at every prompt so
# the two numbering schemes never have to be reconciled in your head.
#
# NOTE: this only tells you what TYPE Wolf most recently created
# for a given slot NUMBER — it can't tell you which PHYSICAL
# controller that was, and it can't let you reassign a physical
# controller to a different slot number. Which controller becomes
# slot 0 vs. slot 1 is decided entirely by Moonlight (the client),
# upstream of Wolf, based on connection order — not something this
# override (or Wolf's API at all) can control.
_ordsuffix() {
case "$1" in
1) echo "st" ;;
2) echo "nd" ;;
3) echo "rd" ;;
*) echo "th" ;;
esac
}
echo ""
echo "Checking Wolf's logs for controllers it has already created a pad for..."
declare -A LAST_SEEN_TYPE
while IFS=$'\t' read -r cnum ctype; do
[ -n "$cnum" ] && LAST_SEEN_TYPE["$cnum"]="$ctype"
done < <(docker compose logs wolf 2>/dev/null | python3 -c "
import re, sys
for line in sys.stdin:
m = re.search(r'Creating (\w+) joypad for controller (\d+)', line)
if m:
print(f'{m.group(2)}\t{m.group(1).upper()}')
")
if [ "${#LAST_SEEN_TYPE[@]}" -gt 0 ]; then
echo "Last type Wolf created, per controller (most recent reconnect wins):"
for cnum in $(printf '%s\n' "${!LAST_SEEN_TYPE[@]}" | sort -n); do
_n=$((cnum + 1))
echo " Your ${_n}$(_ordsuffix "$_n") controller (Wolf calls it \"controller $cnum\"): last seen as ${LAST_SEEN_TYPE[$cnum]}"
done
else
echo "No controllers found in Wolf's logs yet — connect them and start a"
echo "stream first if you want this list populated (not required, you can"
echo "still answer blind below)."
fi
echo ""
echo "Now, separately: how many physical GAMEPADS/CONTROLLERS do you have"
echo "connected to that device right now (this tool can't detect that —"
echo "count your own controllers)?"
read -r -p "Number of controllers [2]: " NSLOTS
NSLOTS="${NSLOTS:-2}"
if ! [[ "$NSLOTS" =~ ^[0-9]+$ ]] || [ "$NSLOTS" -lt 1 ]; then
echo "Invalid number."
exit 1
fi
OVERRIDE_TYPES=()
for i in $(seq 1 "$NSLOTS"); do
cnum=$((i - 1))
SEEN="${LAST_SEEN_TYPE[$cnum]:-}"
DEFAULT="${SEEN:-AUTO}"
SUF="$(_ordsuffix "$i")"
if [ -n "$SEEN" ]; then
read -r -p " Your ${i}${SUF} controller (controller $cnum, last seen as $SEEN) — force to which type? (AUTO/XBOX/PS/NINTENDO) [$DEFAULT]: " T
else
read -r -p " Your ${i}${SUF} controller (controller $cnum, not seen yet) — force to which type? (AUTO/XBOX/PS/NINTENDO) [AUTO]: " T
fi
T="${T:-$DEFAULT}"
T="$(echo "$T" | tr '[:lower:]' '[:upper:]')"
case "$T" in
AUTO|XBOX|PS|NINTENDO) ;;
*) echo " Unrecognized '$T' — using AUTO."; T="AUTO" ;;
esac
OVERRIDE_TYPES+=("$T")
done
BODY=$(python3 - "$CLIENT_ID" "${OVERRIDE_TYPES[@]}" << 'BODYPY'
import json, sys
client_id = sys.argv[1]
overrides = sys.argv[2:]
print(json.dumps({"client_id": client_id, "app_state_folder": None,
"settings": {"controllers_override": overrides}}))
BODYPY
)
RESP=$(sudo curl -fsS --unix-socket "$SOCK" -X POST http://localhost/api/v1/clients/settings \
-H 'Content-Type: application/json' -d "$BODY")
echo "$RESP" | python3 -c "
import json, sys
try:
d = json.load(sys.stdin)
print('Updated.' if d.get('success') else 'Failed: ' + json.dumps(d))
except Exception:
print('Unexpected response from Wolf API.')
"
echo ""
echo "Reconnect (or fully restart) the Moonlight stream for this to take effect —"
echo "it's applied when Wolf creates each controller's virtual pad, not retroactively"
echo "to one that already exists in an open session."
;;
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 controllers - Force distinct pad types per controller slot (multi-controller fix)"
echo " ./manage.sh update - Pull latest Wolf image and restart"
echo " ./manage.sh apps - Add / update game launchers in Wolf"
echo " ./manage.sh cores [all|common] - Download/refresh RetroArch cores (retro ROMs)"
echo " ./manage.sh reorder - Reorder the Moonlight tile list"
echo " ./manage.sh add-web [name] [url] - Add a URL shortcut tile (Firefox kiosk)"
echo " ./manage.sh ge-proton [version] - Install GE-Proton (latest, or pin a version)"
echo " ./manage.sh games - List games; pick one to apply the EA fix"
echo " ./manage.sh setup-swbf2 - Full SWBF2 2017 setup (EA Desktop + wrapper, see docs/SWBF2-2017-wolf-setup.md)"
echo " ./manage.sh fix-ea-game [appid] - Apply EA fix directly (default: SWBF2 1237950)"
echo " ./manage.sh wait-ea-app [appid] - Watch for EA App to install, then register link2ea:// handler"
echo " ./manage.sh install-ea-app [appid] - Run EA App installer inside container (GUI appears in Moonlight)"
echo " ./manage.sh diagnose-ea [appid] - Show link2ea:// registry state and Proton log"
echo " ./manage.sh fix-perms - Fix 'Permission denied' app startup errors"
echo " ./manage.sh install-completion - Enable tab-completion for this script"
echo " ./manage.sh backup - How to set up backups"
;;
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 $WOLF_CFG (on the game drive) on first start. Wait for it,
# then wire in game storage.
log_info "Waiting for Wolf to generate config.toml at $WOLF_CFG..."
local _i
for _i in $(seq 1 30); do
[ -f "$WOLF_CFG" ] && break
sleep 2
done
if [ -f "$WOLF_CFG" ]; then
log_info "Injecting selected apps into Wolf config..."
python3 - "$GAME_STORAGE_DIR" "$WOLF_CFG" "$SITE_TZ" $([[ -n "$_APP_KEYS" ]] && echo "$_APP_KEYS" || echo "steam esde") << 'PYEOF'
import sys, json
games = sys.argv[1].rstrip('/')
cfg = sys.argv[2]
TZ = sys.argv[3].strip()
selected = set(sys.argv[4:]) # app keys chosen by the user
# ── App catalog ───────────────────────────────────────────────────────────────
# Each entry: (wolf_name, title, icon_url, image, mounts, env, cap_add,
# security_opt, ipc_mode, ulimits, privileged)
STD_CAP = ['NET_RAW', 'MKNOD', 'NET_ADMIN']
STD_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*']
STD_RULES = ['c 13:* rmw', 'c 244:* rmw']
# ES-DE gets its own env: same as STD_ENV but with /dev/uinput added to
# GOW_REQUIRED_DEVICES (a second, duplicate-keyed GOW_REQUIRED_DEVICES entry
# alongside STD_ENV's own would be ambiguous to whichever engine reads it, so
# this is a full replacement, not an addition to STD_ENV) — AntiMicroX (see
# the antimicrox setup step below) needs to open /dev/uinput itself to inject
# synthetic key/mouse events under Sway/Wayland (XTest, its other backend,
# needs Xwayland, which this container doesn't run). Paired with
# devices=['/dev/uinput:/dev/uinput'] on the esde catalog entry itself below
# — GOW_REQUIRED_DEVICES alone only gets the base image's own entrypoint
# script to bind-mount the node; the container also needs Wolf's own
# create-time device grant to actually open it (matches the real
# games-on-whales/wolf config.toml example for Steam's own uinput access).
ESDE_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/uinput /dev/input/* /dev/dri/* /dev/nvidia*']
CATALOG = {
'steam': dict(
name='WolfSteam', title='Steam',
icon='https://games-on-whales.github.io/wildlife/apps/steam/assets/icon.png',
image='ghcr.io/games-on-whales/steam:edge',
mounts=['/etc/localtime:/etc/localtime:ro', '/etc/timezone:/etc/timezone:ro',
f'{games}/steam-cache:/home/retro/.cache:rw'],
env=['PROTON_LOG=1', 'RUN_SWAY=true',
'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'],
cap_add=['SYS_ADMIN', 'SYS_NICE', 'SYS_PTRACE', 'NET_RAW', 'MKNOD', 'NET_ADMIN'],
security_opt=['seccomp=unconfined', 'apparmor=unconfined'],
ipc_mode='host',
ulimits=[{'Name': 'nofile', 'Hard': 10240, 'Soft': 10240}],
privileged=False,
),
'esde': dict(
name='WolfES-DE', title='EmulationStation',
icon='https://games-on-whales.github.io/wildlife/apps/es-de/assets/icon.png',
image='ghcr.io/games-on-whales/es-de:edge',
mounts=[f'{games}/roms:/ROMs:rw',
f'{games}/saves:/mnt/games/saves:rw',
f'{games}/media:/media:rw',
f'{games}/bios:/home/retro/bioses:rw',
f'{games}/retro-home:/home/retro/.config:rw',
f'{games}/retro-home-data:/home/retro/.local/share:rw',
f'{games}/retroarch:/home/retro/.config/retroarch:rw',
f'{games}/emulators:/mnt/games/emulators:rw',
f'{games}/emulators:/home/retro/Applications:rw',
f'{games}/esde-custom-systems:/home/retro/ES-DE/custom_systems:rw',
# ~/ES-DE (settings, gamelists, downloaded_media, logs) has
# NO mount at all otherwise (confirmed against ES-DE's own
# source and GOW's es-de startup.sh: getAppDataDirectory()
# is a plain $HOME/ES-DE, no XDG redirect) — Wolf normally
# reuses the same app container across sessions rather than
# recreating it each time (confirmed against Wolf's own
# docker.cpp: it only removes the container on session end
# if WOLF_STOP_CONTAINER_ON_EXIT=TRUE, which this repo never
# sets), so this doesn't get wiped every reconnect — but it
# IS lost on any real reinstall/container recreate, unlike
# everything else here which lives on the game drive. Only
# mounting settings/ specifically (not the whole ~/ES-DE
# tree) keeps this additive and non-breaking alongside the
# existing custom_systems mount above — gamelists/scraped
# media durability is a separate, not-yet-done improvement.
f'{games}/esde-settings:/home/retro/ES-DE/settings:rw'],
env=ESDE_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host',
ulimits=[], privileged=False,
devices=['/dev/uinput:/dev/uinput'],
),
'lutris': dict(
name='WolfLutris', title='Lutris',
icon='https://games-on-whales.github.io/wildlife/apps/lutris/assets/icon.png',
image='ghcr.io/games-on-whales/lutris:edge',
mounts=[f'{games}/lutris:/mnt/games/lutris:rw'],
env=['RUN_SWAY=true',
'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*'],
cap_add=['SYS_ADMIN', 'NET_RAW', 'MKNOD', 'NET_ADMIN'],
security_opt=['seccomp=unconfined', 'apparmor=unconfined'],
ipc_mode='host', ulimits=[], privileged=False,
),
'retroarch': dict(
name='WolfRetroArch', title='RetroArch',
icon='https://games-on-whales.github.io/wildlife/apps/retroarch/assets/icon.png',
image='ghcr.io/games-on-whales/retroarch:edge',
mounts=[f'{games}/roms:/ROMs:rw',
f'{games}/saves:/mnt/games/saves:rw',
f'{games}/bios:/home/retro/bioses:rw',
f'{games}/retro-home:/home/retro/.config:rw',
f'{games}/retro-home-data:/home/retro/.local/share:rw',
f'{games}/retroarch:/home/retro/.config/retroarch:rw'],
env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host',
ulimits=[], privileged=False,
),
'prismlauncher': dict(
name='WolfPrismLauncher', title='Prism Launcher',
icon='https://games-on-whales.github.io/wildlife/apps/prismlauncher/assets/icon.png',
image='ghcr.io/games-on-whales/prismlauncher:edge',
mounts=[f'{games}/minecraft:/mnt/games/minecraft:rw'],
env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host',
ulimits=[], privileged=False,
),
'kodi': dict(
name='WolfKodi', title='Kodi',
icon='https://games-on-whales.github.io/wildlife/apps/kodi/assets/icon.png',
image='ghcr.io/games-on-whales/kodi:edge',
mounts=[f'{games}/kodi:/mnt/games/kodi:rw'],
env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host',
ulimits=[], privileged=False,
),
'firefox': dict(
name='WolfFirefox', title='Firefox',
icon='https://games-on-whales.github.io/wildlife/apps/firefox/assets/icon.png',
image='ghcr.io/games-on-whales/firefox:edge',
mounts=[f'{games}/firefox:/mnt/games/firefox:rw'],
env=STD_ENV, cap_add=STD_CAP, security_opt=[], ipc_mode='host',
ulimits=[], privileged=False,
),
'desktop': dict(
name='WolfDesktop', title='Desktop',
# NOT "desktop" — ghcr.io/games-on-whales/desktop never existed.
# Confirmed live: Wolf logged "[DOCKER] error 404 - No such image:
# ghcr.io/games-on-whales/desktop:edge" and silently dropped back to
# the Moonlight app list with no other indication of failure. The
# games-on-whales/gow repo's apps/ directory names this app "xfce",
# and ghcr.io/games-on-whales/xfce:edge is the real, currently
# published image (confirmed against the GHCR package's own tag
# list). The icon path uses the same "xfce" naming.
icon='https://games-on-whales.github.io/wildlife/apps/xfce/assets/icon.png',
image='ghcr.io/games-on-whales/xfce:edge',
# Shares the SAME persistent home (.config/.local/share) and
# emulators/ -> ~/Applications mount as esde/retroarch below — a real
# XFCE multi-window session is a much more reliable place to run a
# standalone emulator's own GUI (Settings/Input dialogs) than inside
# ES-DE's single-app Sway kiosk session, where a second top-level
# window (e.g. Cemu's own Settings dialog) can fail to ever get
# mapped/focused. Whatever gets configured here (Cemu's settings.xml,
# controllerProfiles/, etc.) is read by the exact same app the next
# time it's launched through ES-DE, since it's the same mounted home.
mounts=[f'{games}/retro-home:/home/retro/.config:rw',
f'{games}/retro-home-data:/home/retro/.local/share:rw',
f'{games}/emulators:/mnt/games/emulators:rw',
f'{games}/emulators:/home/retro/Applications:rw',
# Same /ROMs path es-de/retroarch use — confirmed live: without
# this, a standalone emulator (Cemu) launched from this XFCE
# session via its own File/Load menu has nothing to browse to
# at all, since /ROMs simply doesn't exist in the container.
f'{games}/roms:/ROMs:rw',
f'{games}/saves:/mnt/games/saves:rw'],
# ghcr.io/games-on-whales/xfce has no libfuse2/libfuse3 at all (checked
# against its own Dockerfile) and, unlike es-de's own Dockerfile
# (which sets this exact env var for the same reason), no fallback
# either — confirmed live: launching an AppImage (Cemu) straight from
# this container failed outright with the standard "AppImages require
# FUSE to run" error. APPIMAGE_EXTRACT_AND_RUN=1 makes every AppImage
# self-extract into a temp dir and run from there instead of trying
# to FUSE-mount itself, matching what already makes AppImages work
# fine when ES-DE launches them.
env=STD_ENV + ['APPIMAGE_EXTRACT_AND_RUN=1'], cap_add=STD_CAP, security_opt=[], ipc_mode='host',
ulimits=[], privileged=False,
),
}
def ensure_tz(env_list):
# Set the container timezone so Steam / EA App / games show local time
# (a wrong/UTC display can confuse EA App's installer). Driven by SITE_TZ.
e = [x for x in env_list if not x.startswith('TZ=')]
if TZ:
e.append(f'TZ={TZ}')
return e
def make_app_block(app):
"""Render a [[profiles.apps]] TOML block from a catalog entry."""
host_cfg = {'IpcMode': app['ipc_mode'], 'CapAdd': app['cap_add'],
'Privileged': app['privileged'], 'DeviceCgroupRules': STD_RULES}
if app['security_opt']: host_cfg['SecurityOpt'] = app['security_opt']
if app['ulimits']: host_cfg['Ulimits'] = app['ulimits']
create_json = json.dumps({'HostConfig': host_cfg}, indent=2)
mounts_str = str(app['mounts']).replace('"', "'")
env_str = str(ensure_tz(app['env'])).replace('"', "'")
devices_str = str(app.get('devices', []))
return (
f"\n"
f" [[profiles.apps]]\n"
f" icon_png_path = '{app['icon']}'\n"
f" start_virtual_compositor = true\n"
f" title = '{app['title']}'\n"
f"\n"
f" [profiles.apps.runner]\n"
f" base_create_json = '''{create_json}\n"
f"'''\n"
f" devices = {devices_str}\n"
f" env = {env_str}\n"
f" image = '{app['image']}'\n"
f" mounts = {mounts_str}\n"
f" name = '{app['name']}'\n"
f" ports = []\n"
f" type = 'docker'\n"
)
def update_field(lines, wolf_name, field, new_value):
# Wolf rewrites config.toml and reformats arrays (mounts/env) as MULTI-LINE,
# so we must replace the entire array (from `<field> =` to its closing `]`),
# not just the first line — otherwise the old elements are left orphaned and
# the file becomes invalid TOML. Always rewrite as a single line, and heal
# any orphaned remnants left by a previously corrupted single-line rewrite.
for i, line in enumerate(lines):
if f"name = '{wolf_name}'" in line:
# Bound the search to just THIS app's own [[profiles.apps]] block —
# a blind +/-25-line window used to reach into a neighboring app's
# block once blocks got short enough (e.g. after collapsing a
# mounts array down to one line), splicing that block's own
# mounts/env field or table header instead. Confirmed live: this
# corrupted config.toml into invalid TOML ("cannot redefine
# existing table 'profiles.apps.runner'") and crash-looped Wolf.
block_start = i
while block_start > 0 and lines[block_start].strip() != '[[profiles.apps]]':
block_start -= 1
block_end = i + 1
while block_end < len(lines) and lines[block_end].strip() not in ('[[profiles.apps]]', '[[profiles]]'):
block_end += 1
for j in range(block_start, block_end):
if lines[j].lstrip().startswith(field + ' ='):
indent = lines[j][:len(lines[j]) - len(lines[j].lstrip())]
k = j
while k < block_end and ']' not in lines[k]:
k += 1
m = k + 1
while m < block_end:
s = lines[m].lstrip()
if s.startswith("'") or s.startswith(']'):
m += 1
else:
break
lines[j:m] = [f"{indent}{field} = {new_value}\n"]
return True
return False
def update_scalar_field(lines, wolf_name, field, new_value):
# Same block-bounding as update_field, but for a single-line scalar
# string field (image, icon_png_path) that Wolf never reformats across
# multiple lines. update_field's array logic (which scans forward
# hunting for a closing ']') isn't safe to reuse here — a scalar line
# has no ']' of its own and that scan would run off into an unrelated
# array field further down the same block (e.g. 'ports = []'),
# corrupting it. Confirmed needed live: a stale 'image' field (e.g. the
# games-on-whales/desktop -> xfce rename) was NOT refreshed by
# update_field, since only 'mounts'/'env' were ever passed to it — an
# already-installed app's broken image reference survived every
# './manage.sh apps' re-run until this was added.
for i, line in enumerate(lines):
if f"name = '{wolf_name}'" in line:
block_start = i
while block_start > 0 and lines[block_start].strip() != '[[profiles.apps]]':
block_start -= 1
block_end = i + 1
while block_end < len(lines) and lines[block_end].strip() not in ('[[profiles.apps]]', '[[profiles]]'):
block_end += 1
for j in range(block_start, block_end):
if lines[j].lstrip().startswith(field + " = '"):
indent = lines[j][:len(lines[j]) - len(lines[j].lstrip())]
lines[j] = f"{indent}{field} = '{new_value}'\n"
return True
return False
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 in config'); sys.exit(1)
first_block = lines[first_start:insert_at]
added, updated = [], []
to_insert = []
for key in list(CATALOG.keys()): # preserve display order
if key not in selected:
continue
app = CATALOG[key]
wolf_name = app['name']
already = any(f"name = '{wolf_name}'" in l for l in first_block)
new_mounts = str(app['mounts']).replace('"', "'")
new_env = str(ensure_tz(app['env'])).replace('"', "'")
new_devices = str(app.get('devices', []))
if already:
ok = update_field(lines, wolf_name, 'mounts', new_mounts)
ok = update_field(lines, wolf_name, 'env', new_env) or ok
# 'devices' is an array like mounts/env (even though it's currently
# always rendered on one line) — Wolf's own config.toml rewrites
# reformat arrays as multi-line, so this needs update_field's
# continuation-scanning logic, not update_scalar_field's single-line
# one, or a devices change (e.g. the esde entry's new /dev/uinput
# grant, added for AntiMicroX) would silently never apply on an
# "already installed" rerun — the same class of bug fixed for
# image/icon_png_path below by giving those their own scalar path.
ok = update_field(lines, wolf_name, 'devices', new_devices) or ok
ok = update_scalar_field(lines, wolf_name, 'image', app['image']) or ok
ok = update_scalar_field(lines, wolf_name, 'icon_png_path', app['icon']) or ok
if ok:
updated.append(app['title'])
else:
to_insert.append(make_app_block(app))
added.append(app['title'])
if to_insert:
block_lines = []
for block in to_insert:
block_lines += [l + '\n' if not l.endswith('\n') else l
for l in block.splitlines()]
new_lines = lines[:insert_at] + block_lines + lines[insert_at:]
with open(cfg, 'w') as f:
f.writelines(new_lines)
elif updated:
with open(cfg, 'w') as f:
f.writelines(lines)
if added: print(f"[INFO] Added: {', '.join(added)}")
if updated: print(f"[INFO] Updated mounts: {', '.join(updated)}")
if not added and not updated:
print('[INFO] All selected apps already present and up to date')
PYEOF
docker compose restart wolf
log_success "Wolf restarted with updated config"
# Nothing else to do for Steam storage: Wolf's state folder lives on the
# game drive, so Steam installs, games, and Proton prefixes land there
# automatically the first time you connect. No fix-perms, no symlinks.
# _APP_KEYS, not APP_KEYS — this install-time copy uses the
# underscore-prefixed name (set earlier in install_wolf()); APP_KEYS
# is manage.sh's own copy's variable name. Confirmed live: this was
# a silent no-op under 'set -u' without 'set -e' (bash prints
# "APP_KEYS: unbound variable" and just continues), so the Steam
# storage-location message never printed but nothing else broke.
if echo "$_APP_KEYS" | grep -qw steam; then
log_success "Steam will install games to $WOLF_STATE_DIR (on the game drive)"
fi
else
log_warning "Wolf config not generated in time. Add apps manually to $WOLF_CFG"
log_warning "Then run: ./manage.sh apps"
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+Q or Back/Select+Start+LB+RB together (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 " bios/ → /home/retro/bioses (emulator BIOS/firmware)"
echo " retroarch/cores/ → ~/.config/retroarch/cores (libretro cores)"
echo " retroarch/shaders/ → ~/.config/retroarch/shaders (shaders/CRT filters — persist)"
echo " retroarch/overlays/ → ~/.config/retroarch/overlays (bezels/overlays — persist)"
echo " saves/ → /home/retro/.config/retroarch/saves (RetroArch saves)"
echo " media/ → /media (ES-DE scraped artwork)"
echo " wolf-state/ → Wolf state: Steam install, games, Proton prefixes,"
echo " ES-DE settings, controller maps, config.toml"
echo ""
echo "── EMULATION (ES-DE) FIRST RUN ───────────────────────"
echo ""
echo " • Drop ROMs in roms/<system>/ (e.g. roms/snes/, roms/genesis/)"
echo " • Drop BIOS files in bios/ (PSX/Saturn/Dreamcast/Neo-Geo need them; PS2/PCSX2 and"
echo " TI-99/4A need their own BIOS/console ROM too, but PS2 is configured inside PCSX2's own"
echo " settings rather than auto-detected from bios/, and TI-99/4A's setup is manual — see below)"
echo " • RetroArch cores are pre-downloaded — retro games launch immediately"
echo " • Shaders, overlays, cheats, and the game database are pre-downloaded too —"
echo " all persist on the game drive and survive new sessions and reinstalls"
echo " • Controllers auto-configure: ES-DE & RetroArch map Wolf's virtual pad"
echo " via SDL, no manual input setup needed"
echo " • Exit a game → ES-DE: Start+Select opens RetroArch menu → Quit,"
echo " or the Moonlight hotkey Back/Select+Start+LB+RB together / Ctrl+Alt+Shift+Q"
echo " • Add / refresh cores later: ./manage.sh cores"
echo ""
echo " Wolf's entire state folder lives on the game drive, so Steam games"
echo " and everything else stay off the OS drive automatically."
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|apps|reorder|add-web|ge-proton|fix-ea-game}"
echo ""
echo "── EA GAMES (Battlefront II, etc.) ───────────────────"
echo ""
echo " EA titles need GE-Proton + a full EA App install:"
echo " 1. ./manage.sh ge-proton # install GE-Proton once"
echo " 2. In Steam: game → Properties → Compatibility → Force GE-Proton"
echo " 3. Click Play once (builds the Proton prefix; hang is expected)"
echo " 4. ./manage.sh install-ea-app # runs EA App installer in the container"
echo " (watch Moonlight — an installer window appears; click through + log in)"
echo " 5. ./manage.sh fix-ea-game # unstick the install-script loop"
echo " 6. Click Play — EA App authenticates and launches the game"
echo ""
echo " Diagnose: ./manage.sh diagnose-ea # check link2ea:// registry state"
echo ""
echo "── BACKUPS ───────────────────────────────────────────"
echo ""
echo " Back up your saves, progress and user data (Wolf's state folder:"
echo " ES-DE settings, controller mappings, RetroArch saves/states, emulator"
echo " saves, Steam user data). ROMs and game installs are skipped."
echo ""
echo " Set up automatic backups with the backup module:"
echo " sudo ./setup.sh backup"
echo ""
# Wolf's web UI (pair/manage) has no built-in auth — protect with Authelia if available
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.
# Wolf — Cloud Gaming (Games-on-Whales)
Stream games to any Moonlight client over your LAN or Tailscale VPN.
## Pair a new client
1. Open Moonlight on the client device
2. Add host: this server's IP
3. Run the pin command on the server:
\`\`\`bash
cd $WOLF_DIR && ./manage.sh pin
\`\`\`
## Manage
\`\`\`bash
cd $WOLF_DIR
./manage.sh start # start Wolf
./manage.sh stop # stop
./manage.sh restart # restart
./manage.sh logs # live logs
./manage.sh status # container status
./manage.sh controllers # force distinct pad types per controller slot (multi-controller fix)
./manage.sh update # pull latest image and restart
./manage.sh apps # add / update game launchers
./manage.sh cores # download/refresh RetroArch cores (retro ROMs)
./manage.sh reorder # reorder the Moonlight tile list
./manage.sh add-web # add a URL shortcut tile (Firefox kiosk)
./manage.sh ge-proton # install GE-Proton (needed for EA games)
./manage.sh games # list installed games + their AppIDs
./manage.sh fix-ea-game # unstick the EA App install loop for a game
./manage.sh install-ea-app # install EA App inside the container (GUI in Moonlight)
./manage.sh diagnose-ea # check link2ea:// state when game returns to Play
./manage.sh install-completion # enable tab-completion for manage.sh
\`\`\`
### Tab completion
Run \`./manage.sh install-completion\` once, then re-open your shell (or
\`source ~/.bashrc\`). After that, \`./manage.sh <TAB><TAB>\` lists all commands.
## Multiple controllers (same game/emulator can't tell them apart)
**Symptom:** two or more controllers connected through the same Moonlight
session, but the game/emulator only ever sees one — the first controller
ends up driving every player, or a second controller's own binding just
doesn't do anything (first reported with Cemu/Wii U, but this isn't
Cemu-specific — it affects any system/app that reads SDL joystick GUIDs).
**Why.** Every virtual gamepad Wolf creates of the *same type* (e.g. two
Wii U Pro Controllers) gets the **identical SDL GUID** — a GUID identifies
a controller *model*, not a physical instance, so this is actually normal,
expected SDL behavior (two real identical physical controllers behave the
exact same way). Some apps' own controller-picker UI just doesn't reliably
tell apart two same-GUID devices though.
**Fix: force each controller slot to a *different* pad type**, so their
vendor/product IDs — and therefore their SDL GUIDs — genuinely differ:
\`\`\`bash
cd $WOLF_DIR && ./manage.sh controllers
\`\`\`
May prompt for your \`sudo\` password — Wolf's API socket is root-owned
(Docker creates its \`/var/run/wolf\` bind-mount source as root, and Wolf
itself runs as root in its container), so the curl calls behind this
command need it, the same as every other command here that touches
Wolf's own root-owned state.
**Identifying which paired client is actually yours:** a bare list of
paired clients is a wall of meaningless numbers (Wolf's own API exposes
no name/IP for a paired-but-not-currently-streaming client) — so this
cross-references Wolf's live sessions list too and tags whichever entry
is actively streaming right now with its real IP address, e.g.
\`ACTIVE - streaming from 192.168.1.42\`. If exactly one client is active
it's used automatically with no prompt. **For the clearest result: start
streaming from the device you want to configure, leave it connected, and
run this command while it's still connected** — then there's no
guessing. Wolf doesn't dedupe repeated pairings of the same device — if
you've paired the same PC more than once, this tool collapses those
into a single listed entry tagged e.g. \`(paired 6x)\` instead of forcing
a choice between several options that all resolve identically (confirmed
live: being asked to pick between 6 identical-outcome entries was
genuinely confusing before this).
**It labels controllers "1st"/"2nd"/"3rd"/"4th" — always meaning Wolf's own
"controller 0"/"controller 1"/"controller 2"/"controller 3".** Wolf's own
log wording is 0-indexed; this tool is deliberately 1-indexed throughout
so the two numbering schemes never have to be reconciled in your head
(confirmed live that mixing them — reading "exactly one client is
active" as "exactly one controller" right above a 0-indexed-sounding
prompt — is a real, easy misread).
Before asking anything, it polls \`docker compose logs wolf\` for every
\`Creating <TYPE> joypad for controller <N>\` line Wolf has ever logged
(most recent per slot wins) and shows you what it last saw, e.g.:
\`\`\`
Your 1st controller (Wolf calls it "controller 0"): last seen as NINTENDO
Your 2nd controller (Wolf calls it "controller 1"): last seen as XBOX
\`\`\`
— then uses that as the suggested default at each prompt, so if nothing's
changed you can just hit Enter through them. **This can't tell you which
physical controller became which slot, and it can't let you reassign
one** — which controller becomes slot 0 vs. slot 1 is decided entirely by
Moonlight (the client), based on connection order, upstream of Wolf and
this tool alike. If you need to know that for certain, connect your
controllers one at a time while following the log live
(\`docker compose logs -f wolf | grep -i controller\`) — you'll see
exactly which slot number each one becomes as it connects.
Then it asks how many controllers to configure and what type to force
each one to. There are only **3 concrete types** (confirmed against Wolf's own
virtual-pad source, \`inputtino\`) — not one per real controller brand:
- \`XBOX\` → an **Xbox One** controller specifically (not Series/360)
- \`PS\` → a **PlayStation 5 DualSense** specifically (no separate PS4 option)
- \`NINTENDO\` → a **Switch Pro Controller**
- \`AUTO\` → auto-detects from whatever the physical controller reports
(this is what causes the collision in the first place — e.g. every
8BitDo pad set to "Switch mode" reports as Nintendo, so AUTO gives all
of them the identical GUID)
e.g. controller 1 = \`NINTENDO\` (matches an 8BitDo pad's native Switch
mode), controller 2 = \`XBOX\`, controller 3 = \`PS\`. This calls Wolf's own
REST API (\`controllers_override\`, per Wolf's own docs' "Override the
default joypad mapping" section) rather than hand-editing \`config.toml\`.
Forcing a non-matching type doesn't break any buttons — Moonlight still
translates your controller's actual button presses onto whichever virtual
pad type you pick — it just means that controller's on-screen button
prompts (e.g. "press ✕") won't visually match what's printed on the
physical pad. Purely cosmetic.
**Reconnect (or fully restart) the Moonlight stream afterward** — this
applies when Wolf creates each controller's virtual pad for a *new*
session, not retroactively to one already open.
If \`./manage.sh controllers\` says the API socket isn't up yet, re-run
\`sudo ./setup.sh wolf\` (regenerates \`docker-compose.yml\` with the socket
mount this command needs) and then \`docker compose up -d\` to recreate the
Wolf container.
### 4 controllers (Cemu / Wii U games)
Wii U hardware itself tops out at 4 local players, and everything upstream
supports at least that many: Cemu's own Input Settings allows up to 8
controller slots (confirmed against Cemu's own wiki — well above what any
Wii U game actually uses), and Wolf's wire protocol tracks controller
slots via a bitmask with no hardcoded 4-controller limit (confirmed
against its own \`control/input_handler.cpp\` source) — the practical
ceiling is whatever your Moonlight *client* supports (commonly 4 on
PC/iOS).
The catch: with only 3 concrete forced types (above), controller 4 can't
get its own guaranteed-unique GUID — it has to reuse \`XBOX\`, \`PS\`, or
\`NINTENDO\` and land back in the same ambiguous-GUID situation with
whichever one it matches.
**Workaround for the 4th controller: bind it in Cemu one at a time.**
Disconnect (or just don't touch) every controller except the one you're
currently binding, then use Cemu's own "press a button to detect" step
for that slot. With only one Wolf virtual pad actually emitting input at
that moment, there's nothing for Cemu's picker to confuse it with,
regardless of which type it's sharing a GUID with. This is a reasoned
workaround, not one confirmed working live — if it doesn't pan out,
that's useful to know.
## ES-DE launches a second game while one is already running
**Symptom** (confirmed live, not Cemu-specific — happens on any system):
a second controller's input reaches ES-DE's own menu in the background
and launches an entirely different game — you'll hear/see it start
playing underneath whatever already has focus.
**Cause:** ES-DE's own **"Run in background (while game is launched)"**
setting. When it's on, ES-DE keeps running and listening to every
controller even after something else has launched and taken visual
focus — ES-DE's own USERGUIDE.md names this exact failure mode directly.
ES-DE's compiled default for this is already off, but nothing durably
enforced that in this setup before now, so it's easy for it to have
gotten toggled on at some point and stick that way.
**This installer now forces it off automatically** (\`esde-settings/es_settings.xml\`,
written fresh on every install/reinstall) — no menu digging required. If
you're on an install from before this existed, re-run \`sudo ./setup.sh wolf\`
to pick it up, or set it by hand: **Main Menu → Other Settings → Run in
background (while game is launched) → off**.
**Why this needed its own mount, not just a one-time write:** \`~/ES-DE\`
(settings, gamelists, scraped artwork, logs) had no bind mount onto the
game drive at all before this — confirmed against ES-DE's own source
(\`getAppDataDirectory()\` is a plain \`$HOME/ES-DE\`, no XDG redirect) and
GOW's own \`es-de\` startup script. In normal day-to-day use this mostly
didn't matter, since Wolf reuses the same app container across sessions
rather than recreating it each time (confirmed against Wolf's own
\`docker.cpp\`: the container is only removed on session end if
\`WOLF_STOP_CONTAINER_ON_EXIT=TRUE\`, which this repo never sets) — but it
meant this setting, and everything else under \`~/ES-DE\`, wasn't safe
across an actual reinstall or container recreate the way roms/saves/BIOS
already are. Only \`settings/\` is mounted now (\`esde-settings/\`, additive,
doesn't touch the existing \`custom_systems\` mount) — gamelists and
scraped media durability is a related gap, not yet fixed.
## EA games (Battlefront II 2017, etc.)
EA titles require GE-Proton and the EA App to be installed inside the Wine
prefix. The EA App handles authentication — without it, the game launches
and immediately returns to the Play screen.
\`\`\`bash
cd $WOLF_DIR
./manage.sh ge-proton # 1. install GE-Proton once
# 2. Steam → game → Properties →
# Compatibility → Force GE-Proton
# 3. Click Play once (builds the prefix;
# it will hang — that's expected)
./manage.sh install-ea-app # 4. runs EA App installer INSIDE the container
# (watch Moonlight — installer GUI appears;
# click through it and log in to EA account)
./manage.sh fix-ea-game # 5. unstick the install-script loop
# 6. Click Play — EA App authenticates + launches
\`\`\`
**Troubleshooting**: If the game returns straight to the Play screen after step 6:
\`\`\`bash
./manage.sh diagnose-ea # shows link2ea:// registry state + Proton log
\`\`\`
Notes:
- Steam **AppIDs are global** — SWBF2 (2017) is always \`1237950\` on every
machine — but you don't need to know it; the \`games\` picker handles it.
- \`fix-ea-game\` is idempotent and only touches the game you pick.
- \`ge-proton\` takes an optional version to pin, e.g.
\`./manage.sh ge-proton GE-Proton10-34\`.
## Ports (open on firewall / router)
| Port(s) | Protocol | Use |
|---------|----------|-----|
| 4798447990 | TCP | Moonlight control |
| 48010 | TCP | RTSP |
| 4799848000 | UDP | RTP video/audio/control |
## Storage layout
Two things live on the game drive (\`$GAME_STORAGE_DIR\`):
**1. Shared media folders** — bind-mounted into app containers:
- \`roms/<system>/\` → /ROMs (EmulationStation — drop ROMs here)
- \`bios/\` → /home/retro/bioses (emulator BIOS/firmware — drop BIOS here)
- \`retroarch/cores/\` → ~/.config/retroarch/cores (libretro cores, pre-downloaded)
- \`retroarch/shaders/\` → ~/.config/retroarch/shaders (shaders/CRT filters — persist)
- \`retroarch/overlays/\` → ~/.config/retroarch/overlays (bezels/overlays — persist)
- \`saves/\` → /mnt/games/saves (RetroArch saves & states)
- \`emulators/\` → /mnt/games/emulators + ~/Applications (Azahar/PCSX2/Dolphin AppImages)
**2. \`wolf-state/\`** — Wolf's entire state folder (\`WOLF_STATE_DIR\` in
\`.env\`). This is the key to keeping games off the OS drive: it holds
\`cfg/config.toml\` plus every app's session home, including the Steam install,
downloaded games, and Proton prefixes.
## Why games land on the game drive (no config needed)
Wolf stores all app state under \`HOST_APPS_STATE_FOLDER\`. We point that — and
the matching docker-compose volume — at \`wolf-state/\` on the game drive, mounted
at the **same path inside and outside** the wolf container so the app containers
Wolf spawns through the Docker socket resolve it correctly on the host. Because
Steam's home is born on the game drive, its installs, games, and Proton prefixes
go there with nothing to configure — no \`libraryfolders.vdf\` seeding, no
symlinks, no \`fix-perms\` dance.
## Emulation (ES-DE) — first run
ES-DE is usable the moment you launch it; only ROMs and BIOS are yours to add.
- **ROMs**: drop files into \`roms/<system>/\` (e.g. \`roms/snes/\`, \`roms/genesis/\`).
Every standard system folder is pre-created.
- **BIOS**: drop firmware into \`bios/\` (mounted at \`~/bioses\`). Required for
PSX, Saturn, Dreamcast, Neo-Geo, PC Engine CD, etc. **PS2/PCSX2 also needs
its own BIOS**, but it's configured inside PCSX2's own settings UI (BIOS
directory) rather than auto-detected from this shared folder — point it at
\`bios/\` there, or its own default location. **TI-99/4A** needs a real
console ROM + GROM dump too — see the TI-99/4A section below.
- **RetroArch cores**: pre-downloaded into \`retroarch/cores/\` and mounted at
\`~/.config/retroarch/cores\`, so libretro games launch right away. Refresh or
expand the set with \`./manage.sh cores\` (\`all\` = full buildbot set + the
extras below, \`common\` = mainstream systems only, add \`force\` to
re-download everything including what's already present).
- **Shaders, overlays, cheats, database, controller autoconfig**: \`./manage.sh
cores all\` also pulls everything else RetroArch's own Online Updater offers
— Slang shaders, overlays/bezels, cheat files, the RDB game database, and
controller autoconfig profiles — straight from the same libretro buildbot,
no manual trip through that menu needed. They land in \`retroarch/{shaders,
overlays,cheats,database,autoconfig}/\`, mounted at the matching
\`~/.config/retroarch/...\` paths, and live on the game drive so anything you
add on top (a CRT shader preset, custom bezels) persists across sessions and
survives a Wolf reinstall. Not included: thumbnails (box art) — those are
hosted separately, are per-system, and can run into many GB; pull them
per-system from RetroArch's own Thumbnails Updater instead.
- **Controllers**: auto-configured. ES-DE and RetroArch map Wolf's virtual pad
through SDL with no manual input setup. RPCS3 ships Wolf-specific bindings.
To remap inside ES-DE: Main menu → Input device settings → Configure
keyboard and controllers.
- **Exit a game** back to ES-DE: Start+Select opens the RetroArch menu → Quit,
or use Moonlight's own quit gesture — Back/Select+Start+LB+RB pressed
together on a controller (Ctrl+Alt+Shift+Q on a keyboard). This is a
Moonlight client feature, not something Wolf itself defines — confirmed
against Moonlight's own documented shortcuts after the previously listed
"START+UP+RB" / "Ctrl+Alt+Shift+W" turned out not to work at all.
## GameCube / Wii / PS2 — prefer RetroArch's own cores over standalone
RetroArch's libretro cores (Dolphin for GameCube/Wii, PCSX2 for PS2) are
downloaded automatically along with the rest of the RetroArch core set
(\`./manage.sh cores\`) and use RetroArch's own universal hotkey binds
(Settings -> Input -> Input Hotkey Binds) — one combo for exit/save-state/
volume/etc. that works the same across every RetroArch-driven system, no
extra tooling needed. **Point ES-DE's GameCube and Wii systems at this
instead of "Dolphin (Standalone)"**: Main Menu -> Other Settings ->
Alternative Emulators -> GameCube / Wii (two separate entries) -> pick the
RetroArch/core-based option. Same config either way — ES-DE's embedded
RetroArch and the standalone RetroArch app (if you added it) share the
same \`retroarch/\` cores/saves/system directories.
**PS2 has three RetroArch-core alternative-emulator labels — PCEE2,
LRPS2, and PCSX2** — and it's worth knowing they're not three different
cores: LRPS2 and PCSX2 both point at the exact same file
(\`pcsx2_libretro.so\`, ES-DE's own naming carried over from when the core
was called LRPS2 upstream), while **PCEE2** (\`pcee2_libretro.so\`) is a
genuinely separate, actively-developed libretro port
([WizzardSK/pcee2-libretro](https://github.com/WizzardSK/pcee2-libretro)).
ES-DE only added PCEE2 as an alternative emulator (as the new *default*,
in fact) in version 3.5.0 — confirmed against ES-DE's own CHANGELOG.md —
which hasn't been released yet as of this writing, so the actual ES-DE
build \`ghcr.io/games-on-whales/es-de:edge\` ships (it always installs the
latest *released* AppImage, confirmed against gow's own Dockerfile) is
still on 3.4.1 and genuinely has no PCEE2 entry at all. This installer
backfills it via \`esde-custom-systems/es_systems.xml\` (same mechanism as
the TI-99/4A and Wii U customizations above) so it shows up as an
alternative — and as the default — regardless. The core file itself needs
no special handling: it's on the official libretro buildbot like every
other core, so the normal RetroArch cores pre-download below already
covers it.
The Dolphin core's \`Sys\` folder (compatibility DB + IPL data it needs to
boot Wii titles at all) is fetched automatically now, right after the core
itself downloads — no more manual trip through RetroArch's own Online
Updater -> Core System Files Downloader.
**If a Wii game shows "This file cannot be used because the data is
corrupted. Delete the file and create a new one?" with the OK button
greyed out** — confirmed live: this isn't a Sys-folder or permissions
problem, and isn't something a fresh install can prevent outright (it's
Dolphin's own generated per-game save data getting stuck mid-write, not a
setup defect) — it's that specific game's own save file wedged in a bad
state. For Mario Kart Wii specifically, delete just its \`rksys.dat\`
(license/ghost/friend-roster data, not your NAND or game files) and let
Dolphin regenerate it fresh:
\`\`\`bash
GAME_DIR=\$(grep '^GAME_STORAGE_DIR=' $WOLF_DIR/.env | cut -d= -f2-)
rm "\$GAME_DIR/retroarch/saves/dolphin-emu/User/Wii/title/00010004/524d4345/data/rksys.dat"
\`\`\`
Other Wii titles hit the same class of issue under their own title ID —
\`find "\$GAME_DIR/retroarch/saves/dolphin-emu/User/Wii/title" -maxdepth 2\`
lists them; delete the specific stuck file inside that title's \`data/\`
folder, not the whole \`Wii/\` tree.
3DS has no viable RetroArch/libretro path at all (Azahar/Citra never
shipped a libretro core), so the standalone AppImage below stays the only
option there. PS2's libretro core is much newer/less mature than Dolphin's
— worth trying the same way, but expect rougher edges.
## Standalone emulators (3DS required; PS2 / GameCube+Wii as a fallback)
ES-DE also hands these off to standalone AppImages, for 3DS (the only
option) or as an alternative to the RetroArch cores above:
- **Azahar** (3DS, open-source Citra fork) — official releases
- **PCSX2** (PS2) — official releases
- **Dolphin** (GameCube/Wii) — Dolphin itself ships no official Linux
AppImage (dolphin-emu.org's own Linux distribution is Flatpak-only); this
uses a well-regarded but **third-party** community AppImage build
(pkgforge-dev) instead. Skip it and install Dolphin's own Flatpak by hand
if you'd rather not run an unofficial build. ES-DE only auto-detects a
file literally named \`Dolphin_Emulator*.AppImage\` — since pkgforge-dev's
own release asset isn't named that, this installer also drops a
\`Dolphin_Emulator.AppImage\` symlink next to the real download pointing at
it, so ES-DE actually finds it. If you ever grab a different Dolphin
AppImage by hand instead, re-point (or recreate) that symlink at it, or
ES-DE will report "emulator not found" even with the file sitting right
there.
All three live in \`emulators/\` → mounted at \`/mnt/games/emulators\` and
\`~/Applications\` (where ES-DE's app finder looks). Point ES-DE's
3DS/PS2/GameCube/Wii systems at one via Alternative Emulators if it isn't
auto-detected. Missing ones get offered again on a fresh install
(\`sudo ./setup.sh wolf\`); to grab just one by hand, download its AppImage
into \`emulators/\`, \`chmod +x\` it.
## Wii U (Cemu)
No libretro core exists for Wii U — Cemu is standalone-only, same download
pattern as the three above (\`emulators/\`, official cemu-project/Cemu
releases, auto-offered on install). Two things worth knowing:
- Cemu's own Linux release assets were briefly compromised in a
supply-chain attack around v2.6 (2026-05, since restored) — this
installer downloads straight from cemu-project's own GitHub releases,
but it's worth being aware of if you want to check checksums yourself.
- Most retail Wii U games are encrypted and need your own Wii U common key
(\`keys.txt\`) — Cemu's own First-Time Setup Wizard covers where that goes.
**Controller and audio setup — do this from the Desktop app, not ES-DE.**
Cemu's own Settings dialogs (Input Settings, General Settings) don't render
correctly inside ES-DE's Sway kiosk session — buttons can be unreachable or
invisible even though the dialog box itself shows up. They work fine from
the **Desktop** (XFCE) app instead, and since Desktop shares the exact same
\`~/.config/Cemu\` as ES-DE, whatever you configure there applies the next
time Cemu launches from ES-DE too.
1. Connect your controller to whichever device is running the **Moonlight
client** (not this server) before connecting.
2. In Moonlight, connect to **Desktop**, open a terminal from its
application menu, and launch Cemu plain: \`~/Applications/Cemu-2.6-x86_64.AppImage\`
3. Controller: **Options → Input Settings → Controller 1** → set Emulated
controller to **Wii U Pro Controller** → click **+** → API **SDL** →
select your controller from the list → use Cemu's own live
button-mapping screen to bind each button → save.
4. Audio: **Options → General Settings → Audio** → set **TV** device to
your real output.
5. If setting the TV device ever hangs Cemu outright (a real, reproducible
Linux Cubeb/PulseAudio stream-open stall, matches cemu-project/Cemu#601
upstream) — a full Wolf restart (\`sudo ./setup.sh wolf\` from this
repo, not just reconnecting) reliably cleared it in testing, giving a
fresh PulseAudio session to work with. Reconnect to a fresh Desktop
session afterward and try again.
6. **Adding a second controller and the first one ends up driving both
characters?** That's not a Cemu-specific bug — see
"Multiple controllers" above (\`./manage.sh controllers\`).
**If Input Settings' Save button is cut off the bottom of the screen**
(confirmed live: happens even maximized, at higher resolutions, and
closing the window doesn't save — Cemu's Input Settings dialog is a
fixed size, not resizable, and can genuinely be taller than the session's
viewport) — try moving the dialog first: hold **Alt** and **left-click-drag
anywhere inside it** (not just the titlebar) to reposition it upward, or
use xfwm4's own **Alt+F7** (grab-to-move, drop with click or Enter) if
that doesn't work. Either is a plain X11/XFCE window-manager trick, not
Cemu-specific, and usually the fastest fix.
**If that still doesn't work, or you'd rather skip the GUI entirely: edit
the controller profile file directly.** Cemu's per-slot controller config
isn't inside the Docker container at all — it's plain XML on the game
drive (Desktop and ES-DE both mount \`~/.config\` from the same place):
\`\`\`bash
GAME_DIR=\$(grep '^GAME_STORAGE_DIR=' $WOLF_DIR/.env | cut -d= -f2-)
ls "\$GAME_DIR/retro-home/Cemu/controllerProfiles/"
\`\`\`
File naming is 0-indexed the same way Wolf's own controller numbering is
(yet another place this comes up) — \`controller0.xml\` is Cemu's
**Controller 1**, \`controller1.xml\` is **Controller 2**, and so on.
Each holds one or more \`<controller>\` blocks (one per SDL device that's
ever been assigned to that slot, identified by its own \`<uuid>\`/
\`<display_name>\`) with a \`<mappings>\` list of \`<mapping>\`/\`<button>\`
pairs. **Confirmed against real captured profiles from different
controller brands: these values are universal SDL_CONTROLLER_BUTTON_*
semantics, not raw per-device button indices** — so a complete, working
\`<mappings>\` block can be copied verbatim from one \`<controller>\` entry
into a different device's empty one in the same file (e.g. a
newly-selected device whose Save got cut off, leaving it with
\`<mappings />\` and nothing bound) without touching Cemu's GUI at all. If
a slot ends up with more than one \`<controller>\` block and only one
device is actually in use, deleting the stale block(s) removes any
ambiguity about which one Cemu picks. Back up the file first
(\`cp controllerN.xml controllerN.xml.bak\`) before hand-editing; no
\`sudo\` needed, these files are written world-writable by Cemu itself.
Close Cemu completely and relaunch for a hand-edited profile to take effect.
ROMs are mounted at \`/ROMs\` inside Desktop too, so Cemu's own File → Load
can browse straight to \`/ROMs/wiiu/\` without needing ES-DE at all.
## TI-99/4A
If you said yes to the TI-99/4A prompt during install, it's set up as a
real ES-DE system (\`roms/ti994a/\`, artwork scraping, gameplay-time
tracking) via a custom system definition at
\`esde-custom-systems/es_systems.xml\` — **not** this repo's \`js99er\`
service, which is browser-based and isn't something ES-DE can launch as a
system.
The emulator itself, \`ti99sim-sdl\` (real SDL2 gamepad support), has no
AppImage — this installer builds it from source (v0.16.0, the original
author's own release — the same one RetroPie's own ti99sim.sh
scriptmodule builds from) inside a throwaway container running the exact
same image ES-DE itself runs (\`ghcr.io/games-on-whales/es-de:edge\`),
guaranteeing compatibility, and drops the result at
\`emulators/ti99sim-sdl\` automatically. Re-run \`sudo ./setup.sh wolf\` if
the build ever needs retrying (network hiccup, etc.) — it skips the build
if the binary's already there.
The one thing this can't do for you: your own TI-99/4A console ROM + GROM
dump — same legal situation as any other console BIOS, and not something
any installer can supply. It needs to be named exactly \`TI-994A.ctg\`
(case-sensitive) and placed at \`emulators/TI-994A.ctg\` — the same
directory as the \`ti99sim-sdl\` binary itself, confirmed against
RetroPie's own \`ti99sim.sh\` (it symlinks the console ROM into the
emulator's install dir, then \`cd\`s there before launching; this repo's
own ES-DE command line does the same \`cd\`-first-then-relative-launch
before running \`ti99sim-sdl\`). If you already had the file somewhere
when the TI-99/4A prompt ran, the installer offered to copy it there for
you (tab-completing path prompt); otherwise copy it there by hand. Then
just drop \`.ctg\`/\`.rpk\`/\`.bin\` cartridge files into \`roms/ti994a/\`.
**Controls.** \`ti99sim-sdl\` maps a real gamepad's stick to the TI-99
joystick directly, and any joystick button beyond the first (the fire
button) to the number keys 1-9 (confirmed against its own source —
\`( VIRTUAL_KEY_E ) ( VK_0 + std::clamp(( int ) event.jbutton.button, 1, 9 ))\`
in \`src/sdl/ti994a-sdl.cpp\`) — there's no source-level path from a
joystick button to 0, Enter, Q, or Esc, so those need a real keyboard
reaching the stream. **Esc exits the emulator** (confirmed against its
own bundled \`doc/README.html\`) and hands focus back to ES-DE once the
process closes. The TI-99/4A's own FCTN function-key row, for anything
needing keys beyond what a joystick provides, is (also confirmed
against that same doc): Alt+1 DEL, Alt+2 INS, Alt+3 ERASE, Alt+4 CLEAR,
Alt+5 BEGIN, Alt+6 PROC'D, Alt+7 AID, Alt+8 REDO, Alt+9 BACK, Alt+= QUIT.
## AntiMicroX: extra gamepad buttons for TI-99/4A and Wii U
**Status: confirmed NOT working for TI-99/4A in live testing** (remapped
buttons didn't do anything); Wii U untested. Root cause not yet found —
possible suspects are the \`--eventgen uinput\` backend not actually
injecting into ES-DE's Sway session from inside that same container, or
AntiMicroX's own \`--hidden\` mode needing a display it doesn't have. Not
under active investigation right now — see "Multiple controllers" above
for the actively-maintained fix for the "second controller doesn't work"
problem instead, which doesn't depend on AntiMicroX at all.
If you said yes to the AntiMicroX prompt during install, both TI-99/4A and
Wii U (Cemu) get a second, separately-labeled launch command in ES-DE —
pick it via ES-DE's own **Alternative emulators** option (per-game or
per-system) instead of the default:
- TI-99/4A: **TI99SIM (AntiMicroX)**
- Wii U: **Cemu (AntiMicroX)**
The default command for both (**TI99SIM (Standalone)** / **Cemu
(Standalone)**) is completely unchanged — picking the AntiMicroX
alternative is opt-in per game/system, not automatic, and every other
ES-DE system is untouched regardless.
**What it's for.** \`ti99sim-sdl\`'s own joystick handling only ever
reaches digit keys 1-9 from a raw gamepad button (see the Controls note
above) — there's no way to send 0, Enter, Q, or Esc from a controller.
AntiMicroX sits between the gamepad and the emulator and remaps specific
buttons (or combos) to whatever keyboard key you want. For Wii U, it's a
different angle on the "second controller doesn't work" problem: Wolf's
virtual pads report identical SDL GUIDs, and AntiMicroX re-emits whatever
it reads as its own distinct virtual device — a plausible fix, not a
confirmed one, worth trying if Cemu still won't tell your controllers
apart.
**Building a profile.** AntiMicroX ships with no profile loaded by
default — build one yourself via its own GUI, the same way Cemu's own
controller/audio setup above uses Desktop instead of ES-DE:
1. Connect to **Desktop** in Moonlight (not ES-DE — AntiMicroX's GUI, like
Cemu's Settings dialogs, needs a real XFCE session to render reliably,
not ES-DE's Sway kiosk).
2. Open a terminal from Desktop's application menu and launch AntiMicroX
(check \`emulators/\` for the exact downloaded filename, e.g.
\`~/Applications/AntiMicroX-x86_64.AppImage\`).
3. Build your mapping (e.g. one gamepad button → Esc, another → Enter,
another → 0) using AntiMicroX's own button-capture UI.
4. Save the profile as exactly:
- \`emulators/antimicrox-profiles/ti994a.gamecontroller.amgp\` for TI-99/4A
- \`emulators/antimicrox-profiles/wiiu.gamecontroller.amgp\` for Wii U
The wrapper scripts look for these exact filenames — anything else is
ignored, and AntiMicroX just starts with no profile loaded instead
(not an error, just no remapping).
5. Quit AntiMicroX in Desktop afterward — it's only meant to run
automatically, scoped to the actual game session, via the wrapper.
**How the scoping works.** Only these two systems' AntiMicroX-labeled
commands launch through a wrapper script
(\`emulators/ti99sim-sdl-antimicrox\` / \`emulators/cemu-antimicrox\`) that
starts AntiMicroX hidden, loads the matching profile if one exists, runs
the real emulator, and kills AntiMicroX again once it exits.
**If remapped buttons don't do anything:** AntiMicroX needs
\`/dev/uinput\` to inject key events under ES-DE's Sway/Wayland session
(its other backend, XTest, needs Xwayland, which this container doesn't
run) — this installer grants that to the EmulationStation app
automatically, but this whole feature is new and hasn't been confirmed on
real hardware yet. Check the EmulationStation container's logs
(\`docker logs\`) if it doesn't behave as expected.
## MAME: sample packs showing up as games
MAME sample packs (audio for a handful of older analog-sound games) use the
exact same \`.zip\`-per-game naming as real ROMs, so if they're sitting in
\`roms/mame/\` alongside your actual ROM set, ES-DE/RetroArch's MAME core
lists them as if they were games too — there's no reliable filename-only
way to tell them apart (a samples zip and a ROM zip for the same game can
share the identical name). They belong in RetroArch's \`system_directory\`
instead, under a MAME-core-specific \`samples/\` subfolder — check
\`retroarch/retroarch.cfg\`'s own \`system_directory\` value before assuming
a path (confirmed live: on this repo's Wolf setup it's actually \`bios/\`,
not RetroArch's usual default of \`retroarch/system/\` — the same thing
that bit Dolphin's \`Sys\` folder earlier), so most likely
\`bios/mame2003-plus/samples/\` for MAME2003-Plus (the exact subfolder name
depends on which MAME core variant you're using), never in \`roms/mame/\`
itself. A samples zip's contents are almost entirely \`.wav\` files where a
real ROM zip's aren't, which is the practical way to tell them apart if
you're not sure which of your files are which.
## Troubleshooting: app exits immediately ("Permission denied")
If a launcher (Steam, etc.) closes the moment it opens, its Wolf-managed home
dir has root-owned files the in-container user (uid 1000) can't write. Fix:
\`\`\`bash
cd $WOLF_DIR && ./manage.sh fix-perms
\`\`\`
Then reconnect from Moonlight.
## Backup
\`\`\`bash
sudo ./setup.sh backup # covers wolf-state/ saves and ES-DE settings
\`\`\`
MD
# ── Pre-download RetroArch cores (so ES-DE retro games launch first run) ──
# The GoW es-de/retroarch images ship with NO libretro cores — only the
# RetroArch frontend. Without cores, every libretro-based game (SNES, NES,
# Genesis, N64, PSX, GBA, ...) fails to launch. Cores download onto the game
# drive at retroarch/cores/ and are bind-mounted into the apps at
# ~/.config/retroarch/cores. Idempotent + resumable via ./manage.sh cores.
if echo "$_APP_KEYS" | grep -qwE 'esde|retroarch'; then
echo ""
local _get_cores=""
prompt_yn "Pre-download all RetroArch cores + shaders/overlays/cheats/database/autoconfig now (~1.8 GB) so retro games work on first launch? (y/n):" "y" _get_cores
if [[ "$_get_cores" =~ ^[Yy]$ ]]; then
log_info "Downloading RetroArch cores + assets into $GAME_STORAGE_DIR/retroarch (this can take a while)..."
bash "$WOLF_DIR/manage.sh" cores all \
|| log_warning "Some cores/assets failed — retry later with: cd $WOLF_DIR && ./manage.sh cores"
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$GAME_STORAGE_DIR/retroarch" 2>/dev/null || true
log_success "RetroArch cores + shaders/overlays/cheats/database ready — retro games (including GameCube/Wii via the Dolphin core) launch on first run"
else
log_info "Skipped — fetch later with: cd $WOLF_DIR && ./manage.sh cores"
fi
fi
# ── Pre-download GE-Proton ────────────────────────────────────────────────
# GE-Proton is required for EA games (Battlefront II etc.) and needs to be
# installed into Steam's compatibilitytools.d after first launch. Download
# the tarball now while we have the user's attention so that
# './manage.sh ge-proton' later is instant (just extracts from cache).
log_info "Pre-downloading GE-Proton for EA game support (~500 MB)..."
(
CACHE_DIR="$WOLF_DIR/ge-proton-cache"
mkdir -p "$CACHE_DIR"
URL=$(curl -sL https://api.github.com/repos/GloriousEggroll/proton-ge-custom/releases/latest \
| grep browser_download_url | grep '\.tar\.gz' | cut -d'"' -f4)
if [ -z "$URL" ]; then
log_warning "Could not fetch GE-Proton URL — run './manage.sh ge-proton' later."
else
NAME=$(basename "$URL" .tar.gz)
CACHED="$CACHE_DIR/$NAME.tar.gz"
if [ -f "$CACHED" ]; then
log_success "GE-Proton already cached: $NAME"
elif curl -L -o "$CACHED" "$URL"; then
echo "$NAME" > "$CACHE_DIR/.version"
log_success "GE-Proton cached: $NAME"
else
log_warning "GE-Proton download failed — run './manage.sh ge-proton' later."
rm -f "$CACHED"
fi
fi
)
local START_WOLF=""
prompt_yn "Start Wolf now? (y/n):" "y" START_WOLF
if [[ "$START_WOLF" =~ ^[Yy]$ ]]; then
docker compose up -d \
&& log_success "Wolf started — pair Moonlight to this server's IP" \
|| log_warning "Start failed — check: docker compose logs"
fi
log_success "Done. Pair Moonlight and play."
}
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_wolf