diff --git a/services/wolf.sh b/services/wolf.sh
index 3de4e00..49cbb8e 100644
--- a/services/wolf.sh
+++ b/services/wolf.sh
@@ -1604,13 +1604,28 @@ GOW_ESDE_SETTINGS_TEMPLATE = """
"""
-if 'ROMDirectory' not in content:
+if not content.strip():
content = GOW_ESDE_SETTINGS_TEMPLATE
else:
content = re.sub(r'[ \t]*[ \t]*\n?', '', content)
line = '\n'
content = content.rstrip('\n') + '\n' + line
+ # CONFIRMED LIVE: a file can already contain a ROMDirectory *key* while
+ # its value is blank (e.g. ES-DE's own first-run "select ROM directory"
+ # step got skipped/cancelled in a headless Moonlight session and ES-DE
+ # saved that empty value back over GOW's own template) — checking only
+ # whether the key exists, as an earlier version of this fix did, treated
+ # that as "already populated" and left the blank path in place, so ES-DE
+ # kept finding zero games even though the fix had technically "run".
+ # Force it to /ROMs (the one true value in this container — see the
+ # 'roms:/ROMs' mount on every app that reads ROMs) whenever it's missing
+ # or empty, independent of whether RunInBackground needed touching.
+ m = re.search(r'[ \t]*\n?', '', content)
+ content = content.rstrip('\n') + '\n' + '\n'
+
with open(path, 'w') as f:
f.write(content)
ESDESETTINGSPY
@@ -2146,7 +2161,12 @@ CATALOG = {
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'],
+ f'{games}/steam-cache:/home/retro/.cache:rw',
+ # Same emulators/ -> ~/Applications mount as esde/retroarch
+ # (see below) — without it, an emulator AppImage (Cemu, etc.)
+ # added as a non-Steam game (./manage.sh steam-add-nonsteam-game)
+ # has no file to actually point Exe at from inside this container.
+ f'{games}/emulators:/home/retro/Applications: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'],
@@ -3533,7 +3553,8 @@ _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"
+ install-ea-app diagnose-ea fix-perms install-completion backup
+ steam-add-nonsteam-game"
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
}
# Register for both 'manage.sh' and './manage.sh' invocation styles
@@ -3798,6 +3819,201 @@ except Exception:
echo "it's applied when Wolf creates each controller's virtual pad, not retroactively"
echo "to one that already exists in an open session."
;;
+ steam-add-nonsteam-game)
+ # Adds an emulator (or any executable already on the game drive, e.g.
+ # under emulators/ — mounted at ~/Applications in every app container,
+ # Steam included since the 'steam' CATALOG entry above) as a
+ # non-Steam game shortcut, written straight into Steam's own binary
+ # shortcuts.vdf. Steam's own "Add a Non-Steam Game" flow needs
+ # interactive Big Picture navigation (browse a file picker, click
+ # through dialogs) — this does the same thing from the command line,
+ # matching this repo's usual no-manual-wizard pattern for anything
+ # with a tedious GUI-only path (see ge-proton/install-ea-app above).
+ #
+ # shortcuts.vdf uses Valve's binary KeyValues format (type-tagged:
+ # 0x00 nested map, 0x01 string, 0x02 int32, 0x08 end-of-map) — NOT
+ # the plain-text VDF format localconfig.vdf uses elsewhere in this
+ # file (see the LaunchOptions patch in setup-swbf2). The writer below
+ # is a small generic parser/serializer for that binary format: it
+ # round-trips any existing entries byte-for-byte (tested against a
+ # synthetic file carrying GUI-set fields — icon, LaunchOptions, tags
+ # — all preserved untouched) and only inserts or replaces the one
+ # entry whose Exe matches what's being added here, so it's safe to
+ # run against a shortcuts.vdf that already has real, hand-configured
+ # shortcuts in it. Re-running with the same target is idempotent
+ # (updates that one entry in place, never a duplicate).
+ if [ -z "${2:-}" ]; then
+ echo "Usage: ./manage.sh steam-add-nonsteam-game [display name]"
+ echo ""
+ echo " A fragment of the filename to match against what's"
+ echo " actually in emulators/ (e.g. 'cemu' matches the real,"
+ echo " versioned Cemu-2.0-x86_64.AppImage) — or the exact"
+ echo " filename, or a full ~/Applications/... path."
+ echo " [display name] Name shown in Steam's Library. Defaults to the"
+ echo " filename with .AppImage/.sh stripped."
+ exit 1
+ fi
+ _SANG_ARG="$2"
+ _SANG_NAME="${3:-}"
+
+ 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
+ _SANG_EMU_DIR="$GAME_DIR/emulators"
+
+ _SANG_BASENAME=$(basename "$_SANG_ARG")
+ if [ -f "$_SANG_EMU_DIR/$_SANG_BASENAME" ]; then
+ _SANG_HOST_FILE="$_SANG_EMU_DIR/$_SANG_BASENAME"
+ else
+ _SANG_HOST_FILE=$(ls "$_SANG_EMU_DIR"/*"$_SANG_ARG"* 2>/dev/null | head -1)
+ fi
+ if [ -z "$_SANG_HOST_FILE" ] || [ ! -f "$_SANG_HOST_FILE" ]; then
+ echo "No file matching '$_SANG_ARG' found in $_SANG_EMU_DIR"
+ echo "What's actually there:"
+ ls "$_SANG_EMU_DIR" 2>/dev/null
+ exit 1
+ fi
+ _SANG_EXE="/home/retro/Applications/$(basename "$_SANG_HOST_FILE")"
+ [ -z "$_SANG_NAME" ] && _SANG_NAME=$(basename "$_SANG_HOST_FILE" | sed -E 's/\.(AppImage|sh|x86_64)$//I')
+
+ STEAM_HOME=$(_steam_home)
+ 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 this command."
+ exit 1
+ fi
+ _SANG_STEAM_UID=$(sudo ls "$STEAM_HOME/.steam/steam/userdata/" 2>/dev/null | head -1)
+ if [ -z "$_SANG_STEAM_UID" ]; then
+ echo "No Steam userdata found. Sign into Steam in Moonlight first, then re-run."
+ exit 1
+ fi
+ _SANG_CFG_DIR="$STEAM_HOME/.steam/steam/userdata/$_SANG_STEAM_UID/config"
+ _SANG_VDF="$_SANG_CFG_DIR/shortcuts.vdf"
+ sudo mkdir -p "$_SANG_CFG_DIR"
+
+ # Steam flushes its own in-memory state back to shortcuts.vdf on exit
+ # (same reason _apply_ea_fix above stops WolfSteam before editing
+ # system.reg) — stop it first or this edit gets silently overwritten
+ # the moment Steam next quits or Wolf tears the session down.
+ _SANG_CONTAINER=$(docker ps --format '{{.Names}}' | grep -i WolfSteam | head -1)
+ if [ -n "$_SANG_CONTAINER" ]; then
+ echo "Stopping Steam so it doesn't overwrite this edit on exit..."
+ docker exec "$_SANG_CONTAINER" pkill -f steam.sh 2>/dev/null || true
+ sleep 3
+ fi
+
+ sudo python3 - "$_SANG_VDF" "$_SANG_EXE" "$_SANG_NAME" "/home/retro/Applications" << 'VDFPY'
+import sys, struct, os, zlib
+
+path, exe_path, app_name, start_dir = sys.argv[1:5]
+
+TYPE_MAP, TYPE_STR, TYPE_INT, TYPE_END = 0x00, 0x01, 0x02, 0x08
+
+def read_cstring(data, i):
+ j = data.index(b'\x00', i)
+ return data[i:j].decode('utf-8', 'replace'), j + 1
+
+def parse_map(data, i):
+ entries = []
+ while True:
+ t = data[i]; i += 1
+ if t == TYPE_END:
+ return entries, i
+ key, i = read_cstring(data, i)
+ if t == TYPE_MAP:
+ val, i = parse_map(data, i)
+ elif t == TYPE_STR:
+ val, i = read_cstring(data, i)
+ elif t == TYPE_INT:
+ val = struct.unpack(' 0:
+ with open(path, 'rb') as f:
+ data = f.read()
+ root, _ = parse_map(data, 0)
+else:
+ root = [['shortcuts', TYPE_MAP, []]]
+
+shortcuts_entry = None
+for e in root:
+ if e[0].lower() == 'shortcuts' and e[1] == TYPE_MAP:
+ shortcuts_entry = e
+ break
+if shortcuts_entry is None:
+ shortcuts_entry = ['shortcuts', TYPE_MAP, []]
+ root.append(shortcuts_entry)
+
+entries_list = shortcuts_entry[2]
+
+quoted_exe = f'"{exe_path}"'
+entries_list[:] = [e for e in entries_list if get_field(e[2], 'exe') != quoted_exe]
+for idx, e in enumerate(entries_list):
+ e[0] = str(idx)
+
+new_index = str(len(entries_list))
+crc_input = (exe_path + app_name).encode('utf-8')
+appid = (zlib.crc32(crc_input) | 0x80000000) & 0xFFFFFFFF
+appid_signed = appid - 0x100000000 if appid >= 0x80000000 else appid
+
+new_entry_fields = [
+ ['appid', TYPE_INT, appid_signed],
+ ['AppName', TYPE_STR, app_name],
+ ['Exe', TYPE_STR, quoted_exe],
+ ['StartDir', TYPE_STR, f'"{start_dir}"'],
+ ['icon', TYPE_STR, ''],
+ ['ShortcutPath', TYPE_STR, ''],
+ ['LaunchOptions', TYPE_STR, ''],
+ ['IsHidden', TYPE_INT, 0],
+ ['AllowDesktopConfig', TYPE_INT, 1],
+ ['AllowOverlay', TYPE_INT, 1],
+ ['OpenVR', TYPE_INT, 0],
+ ['Devkit', TYPE_INT, 0],
+ ['DevkitGameID', TYPE_STR, ''],
+ ['DevkitOverrideAppID', TYPE_INT, 0],
+ ['LastPlayTime', TYPE_INT, 0],
+ ['FlatpakAppID', TYPE_STR, ''],
+ ['tags', TYPE_MAP, []],
+]
+entries_list.append([new_index, TYPE_MAP, new_entry_fields])
+
+with open(path, 'wb') as f:
+ f.write(serialize_map(root))
+print(f"Wrote shortcuts.vdf: {app_name} -> {exe_path} (appid {appid_signed})")
+VDFPY
+ sudo chown 1000:1000 "$_SANG_VDF"
+
+ echo ""
+ echo "Added '$_SANG_NAME' -> $_SANG_EXE to Steam's shortcuts.vdf."
+ echo "Open Steam in Moonlight (it will restart since it was stopped above) —"
+ echo "the new tile appears in your Library (may need the Library view, not just Home)."
+ echo "First launch: right-click it -> Properties -> Compatibility, and confirm"
+ echo "'Force the use of a specific Steam Play compatibility tool' is OFF — Cemu and"
+ echo "other native Linux AppImages don't run through Proton."
+ ;;
pin)
# Wolf logs: "Insert pin at http://SOMEIP:47989/pin/#HEXHASH"
# Extract just the hash fragment and build URLs for every interface
@@ -3850,6 +4066,8 @@ except Exception:
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 steam-add-nonsteam-game [name] [display name]"
+ echo " - Add an emulator (emulators/) as a non-Steam game, no GUI needed"
echo " ./manage.sh backup - How to set up backups"
;;
esac
@@ -3906,7 +4124,12 @@ CATALOG = {
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'],
+ f'{games}/steam-cache:/home/retro/.cache:rw',
+ # Same emulators/ -> ~/Applications mount as esde/retroarch
+ # (see below) — without it, an emulator AppImage (Cemu, etc.)
+ # added as a non-Steam game (./manage.sh steam-add-nonsteam-game)
+ # has no file to actually point Exe at from inside this container.
+ f'{games}/emulators:/home/retro/Applications: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'],
@@ -4404,12 +4627,44 @@ cd $WOLF_DIR
./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
+./manage.sh steam-add-nonsteam-game # add an emulator as a non-Steam game, no GUI needed
\`\`\`
### Tab completion
Run \`./manage.sh install-completion\` once, then re-open your shell (or
\`source ~/.bashrc\`). After that, \`./manage.sh \` lists all commands.
+## Adding an emulator to Steam as a non-Steam game
+Steam's own "Add a Non-Steam Game" is a Big Picture file-browser flow —
+usable through Moonlight, but slow to click through for something you're
+scripting or repeating. This does the same thing directly:
+\`\`\`bash
+cd $WOLF_DIR && ./manage.sh steam-add-nonsteam-game cemu
+\`\`\`
+The first argument is matched against whatever's actually in \`emulators/\`
+(so \`cemu\` finds the real, versioned \`Cemu-2.0-x86_64.AppImage\` without
+you having to type the exact filename); an optional second argument sets
+the display name shown in Steam's Library. It stops Steam (so the edit
+survives Steam's own next exit), writes the entry into Steam's
+\`shortcuts.vdf\` directly, and restarts it — open Steam in Moonlight
+afterward and the new tile is in your Library. Re-running with the same
+target updates that one entry rather than creating a duplicate, and any
+shortcuts you've already added by hand through Steam's own UI are left
+alone.
+
+**Why you might want this over ES-DE for something like Cemu:** Steam Input
+tracks each physical controller by its own device path rather than by SDL
+GUID, so — unlike ES-DE/Cemu's native SDL-based controller handling — it
+can potentially hand a game distinct, explicitly-assigned virtual
+controllers per player slot even when the physical controllers are
+identical models (the same case \`./manage.sh controllers\` exists to work
+around for ES-DE). Add Cemu here, then in Steam's own Big Picture
+Controller Settings you can see and assign each detected controller
+individually. This hasn't been confirmed live yet against Wolf's
+container-created virtual controllers specifically (real hardware behaves
+this way; whether Steam Input sees Wolf's virtual joypads the same way is
+still to be tested) — worth trying before assuming it works.
+
## 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
@@ -4550,21 +4805,26 @@ default behavior here.
It writes \`esde-settings/es_settings.xml\` directly from GOW's own real
template (100+ settings, fetched from GOW's own repo, byte-for-byte
identical except \`RunInBackground\` flipped to off) whenever that file is
-either missing or still the old broken placeholder — detected by checking
-for \`ROMDirectory\`, a key only GOW's own bootstrap or ES-DE itself ever
-writes. If the file already has a real, fully-populated value (because
-EmulationStation has already launched at least once and GOW wrote its own
-copy), only the one \`RunInBackground\` line is patched, leaving every
-other setting (theme, scraper prefs, etc.) untouched. Either way, one
+missing or empty. If the file already has real content, only the one
+\`RunInBackground\` line is patched, leaving every other setting (theme,
+scraper prefs, etc.) untouched — **except \`ROMDirectory\`, which is always
+independently forced to \`/ROMs\` whenever its value is blank**, regardless
+of whether the rest of the file looks populated. Either way, one
\`sudo ./setup.sh wolf\` run is enough — no need to connect via Moonlight
-first. **Confirmed live, the hard way, twice:** earlier versions of this
-fix either pre-created a minimal stub that made GOW skip writing its own
-template (breaking ROM discovery on every system, since the ROM path fell
-back to something other than \`/ROMs\`), or only patched an already-existing
-file and left a genuinely missing/broken one alone, requiring exactly the
-manual Moonlight-connect step this version removes. Setting it by hand
-still works too, any time: **Main Menu → Other Settings → Run in
-background (while game is launched) → off**.
+first. **Confirmed live, the hard way, three times:** earlier versions of
+this fix (1) pre-created a minimal stub that made GOW skip writing its own
+template, breaking ROM discovery on every system since the ROM path fell
+back to something other than \`/ROMs\`; (2) only patched an already-existing
+file and left a genuinely missing/broken one alone, requiring a manual
+Moonlight-connect step; and (3) treated the mere *presence* of the
+\`ROMDirectory\` key as proof the file was already correctly populated —
+but ES-DE can write that key with a **blank value** (its own first-run
+"select ROM directory" step going unanswered in a headless Moonlight
+session saves an empty path back over GOW's template), which the presence
+check alone couldn't tell apart from a real one. Setting it by hand still
+works too, any time: **Main Menu → Other Settings → Run in background
+(while game is launched) → off**, and the ROM directory under **Main Menu
+→ Other Settings → ROM directory → \`/ROMs\`** if it's ever blank again.
**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