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