From a212be09c381b29a7b9f5dd2ceee6c6ab56082c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 18:52:31 +0000 Subject: [PATCH] wolf: add ./manage.sh steam-add-nonsteam-game, mount emulators/ into Steam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding an emulator (Cemu, etc.) to Steam as a non-Steam game previously needed Steam's own Big Picture file-browser flow through Moonlight. This writes the shortcut directly into Steam's binary shortcuts.vdf instead, matching this repo's existing no-manual-wizard pattern (ge-proton, install-ea-app). Motivated by wanting to test whether Steam Input's per-device controller tracking (distinct device paths, not SDL GUIDs) can hand Cemu 4 explicitly-assigned controllers where ES-DE/Cemu's own SDL-based handling can't tell identical controllers apart. - New generic binary VDF (KeyValues) parser/serializer: round-trips any existing shortcuts.vdf entries byte-for-byte and only inserts/replaces the one entry matching the given Exe path, so it's safe against a file that already has real, hand-configured shortcuts. Verified in isolated /tmp harnesses: fresh file, idempotent re-add, a second distinct entry, and preserving a synthetic pre-existing GUI-set entry (icon, LaunchOptions, tags) untouched. - Steam's own CATALOG entry had no mount for emulators/ at all (unlike esde/retroarch) — added emulators:/home/retro/Applications so an AppImage is actually reachable from inside the Steam container. Like the ES-DE settings mount, this only takes effect on a freshly created WolfSteam container (Wolf reuses existing ones) — noted in the README. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6 --- services/wolf.sh | 246 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 243 insertions(+), 3 deletions(-) diff --git a/services/wolf.sh b/services/wolf.sh index 3de4e00..7e77269 100644 --- a/services/wolf.sh +++ b/services/wolf.sh @@ -2146,7 +2146,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 +3538,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 +3804,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 +4051,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 +4109,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 +4612,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