wolf: add ./manage.sh steam-add-nonsteam-game, mount emulators/ into Steam
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
This commit is contained in:
+243
-3
@@ -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 <name-or-path> [display name]"
|
||||
echo ""
|
||||
echo " <name-or-path> 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('<i', data[i:i + 4])[0]
|
||||
i += 4
|
||||
else:
|
||||
raise ValueError(f"unknown VDF type 0x{t:02x} at offset {i}")
|
||||
entries.append([key, t, val])
|
||||
|
||||
def serialize_map(entries):
|
||||
out = bytearray()
|
||||
for key, t, val in entries:
|
||||
out.append(t)
|
||||
out += key.encode('utf-8') + b'\x00'
|
||||
if t == TYPE_MAP:
|
||||
out += serialize_map(val)
|
||||
elif t == TYPE_STR:
|
||||
out += val.encode('utf-8') + b'\x00'
|
||||
elif t == TYPE_INT:
|
||||
out += struct.pack('<i', val)
|
||||
out.append(TYPE_END)
|
||||
return bytes(out)
|
||||
|
||||
def get_field(entry_map, key):
|
||||
for k, t, v in entry_map:
|
||||
if k.lower() == key.lower():
|
||||
return v
|
||||
return None
|
||||
|
||||
if os.path.exists(path) and os.path.getsize(path) > 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 <TAB><TAB>\` 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
|
||||
|
||||
Reference in New Issue
Block a user