12 Commits
Author SHA1 Message Date
Outis 39387b5e0f Merge pull request #442 from outis1one/claude/wolf-cemu-four-controllers-373xn8
Claude/wolf cemu four controllers 373xn8
2026-09-05 15:00:15 -04:00
Claude e67ac50c61 wolf: document the ES-DE/RetroArch-in-Steam workflow in the generated README
The steam-setup-frontends command and the ES-DE/RetroArch AppImage
download step had no matching section in the ~/docker/wolf/README.md
content this file generates, unlike every other manage.sh command. Adds
one, alongside the existing Cemu-in-Steam section: why you'd want it,
how the mounts/cores are already shared, and the two honest caveats
(returning to Steam from ES-DE only works via this path, and whether
controller mappings sync across separately-paired Wolf clients is
expected but unconfirmed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-05 18:58:04 +00:00
Claude 3ebbeba672 wolf: add steam-setup-frontends to wait for Steam sign-in then auto-wire ES-DE/RetroArch
Steam Guard's QR-code sign-in can't be scripted (needs a phone approving
a prompt), so this polls for it instead: starts Wolf if needed, waits
for Steam's userdata/ to appear (or proceeds immediately if already
signed in), then re-invokes the existing steam-add-nonsteam-game command
for whichever of ES-DE.AppImage/RetroArch.AppImage was downloaded during
install. Points to ./manage.sh cores all for the shared cores/shaders/
overlays directory rather than duplicating that download logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 21:30:24 +00:00
Claude 07394769ca wolf: add optional ES-DE/RetroArch AppImage downloads for Steam Input
Both projects ship official standalone Linux AppImages separate from
the esde/retroarch Wolf catalog containers this repo already runs.
Adding either one to Steam as a non-Steam game (steam-add-nonsteam-game,
now reaching the same roms/saves/bios/retro-home/retroarch mounts as
the esde/retroarch containers) lets Steam Input assign a 4th controller
its own identity by device path, past Wolf's 3-concrete-pad-type ceiling.

ES-DE is hosted on GitLab rather than GitHub, so this adds a GitLab
Releases API counterpart to the existing GitHub-based download helper.
RetroArch's own buildbot doesn't publish through either API, so this
uses hizzlekizzle/RetroArch-AppImage, the community nightly-build
project the AppImage catalogs themselves point to (flagged as
third-party, same treatment this file already gives the Dolphin
community build). Both downloads are opt-in (default no) and symlink
to a fixed filename so steam-add-nonsteam-game's case-sensitive
substring match finds them regardless of the vendor's own asset name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 21:04:00 +00:00
Claude ae939c4085 wolf: mount ROMs/saves/BIOS/retro-home/retroarch into the Steam container
Lets a manually-downloaded ES-DE or RetroArch AppImage, added via
./manage.sh steam-add-nonsteam-game, see the same library, cores, and
ES-DE settings/custom systems (TI-99, Wii U) the esde/retroarch
containers already have, instead of starting from an empty config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Z4nqULUEipWNgBsPoSuAb
2026-09-04 14:15:33 +00:00
Outis 28996eff57 Merge pull request #441 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: fix cemu-sync-controllers silently finding zero joysticks
2026-09-03 22:48:40 -04:00
Claude 93efe0d607 wolf: fix cemu-sync-controllers silently finding zero joysticks
docker exec needs an explicit -i flag to forward stdin into the
container process; without it, the heredoc piped into `python3 -`
never reached the containerized script, which ran empty and printed
nothing. cemu-sync-controllers then misread that empty output as
"SDL reported zero joysticks" — a false negative with a working set of
4 controllers already confirmed live in ES-DE, not an actual SDL or
GUID problem. Root-caused against the user's own live output: the
exact same probe script, run directly (not via the manage.sh command),
found real device nodes (js0-js4) present in the same container at the
same time cemu-sync-controllers reported zero.

Audited every other docker exec call in this file for the same
stdin-via-heredoc pattern; this was the only one missing -i.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-04 02:48:08 +00:00
Outis 79861c72f3 Merge pull request #440 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
wolf: add ./manage.sh cemu-sync-controllers — full auto-clone via SDL
2026-09-03 22:40:33 -04:00
Claude 2422ce1385 wolf: add ./manage.sh cemu-sync-controllers — full auto-clone via SDL
cemu-clone-controller still needed one manual Cemu GUI step per new
device (click +, select it, map one button, Save) just to learn its
real uuid. This eliminates that too: instead of getting the uuid from
Cemu, it asks SDL directly (SDL_JoystickGetDeviceGUID/GetGUIDString via
ctypes against libSDL2 — the exact library Cemu itself links against)
for the live GUID of every controller connected to the active session,
then clones the proven-working mapping onto each one automatically.

Confirmed live end-to-end on the user's real box: querying SDL this
way inside a running ES-DE session reproduced the exact uuids Cemu had
already written by hand for two different controllers (Nintendo Switch
Pro and Xbox One S) — byte-for-byte identical to their real
controllerProfiles/controllerN.xml. This is what makes trusting SDL as
the uuid source safe, after an earlier attempt to reverse-engineer the
GUID's CRC16 portion by hand (tried 7 different CRC16 variants) failed
to match either device.

Also improved cemu-clone-controller's sibling: the synced profile's
display_name now comes from SDL's own live device name (when the probe
reports one) rather than always inheriting the mapping template's name,
which was a real but purely cosmetic issue caught while re-testing.

Verified end-to-end in an isolated harness against the real captured
controller0.xml/controller1.xml: idempotent skip on already-synced
slots (no needless backups, existing files left byte-identical), a new
slot correctly cloned with the live name and same 24-pair mapping set,
and valid XML output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-04 02:25:22 +00:00
Outis 6fc6c3b84d Merge pull request #439 from outis1one/claude/wolf-pair-port-conflict-7nz8qg
Claude/wolf pair port conflict 7nz8qg
2026-09-03 20:52:47 -04:00
Claude e6522eadec wolf: add ./manage.sh cemu-clone-controller
Cemu's own Wolf-facing controller slots (up to 4) still each need a
separate manual bind through Cemu's Input Settings even after
./manage.sh controllers makes them distinct SDL devices — that dialog's
Save button is documented elsewhere in this README as getting cut off
the viewport, making binding all 4 slots by hand painful.

Validated the underlying assumption against two real, separately
hand-mapped profiles pulled live from a working install (Nintendo
Switch Pro on controller0.xml, Xbox One S on controller1.xml): both
contain the exact same 24 <mapping>/<button> pairs, just in a different
order — real proof Cemu's mapping format is device-agnostic and
order-independent, not just the README's prior unverified claim.

New command clones a proven-working <mappings> block onto a new
device's controllerN.xml, needing only that device's own real <uuid>
(which still has to come from Cemu itself — a hand-computed SDL GUID
risks not matching what SDL actually reports for the live device, so
this never guesses one). Getting that uuid only needs a single-button
minimal bind in Cemu's UI, not full mapping, since only the uuid gets
kept from it.

Verified end-to-end against the real captured controller0.xml/
controller1.xml content: cloning controller0's mappings onto
controller1's real uuid reproduces the exact same 24-pair set Cemu
itself wrote, and the output validates as well-formed XML.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-04 00:45:22 +00:00
Claude 2dcfaafc87 wolf: add Cemu TV-audio-stuck diagnostic block to the generated README
Copyable follow-up for the existing Cubeb/PulseAudio hang note: checks
whether a full Wolf restart has actually happened, whether PulseAudio
inside the Desktop container has any sinks at all, and pulls Cemu's own
log.txt plus its current <Audio> settings.xml block — narrows "stuck on
Disabled" down to a container-level audio problem vs. Cemu's own
device-switch path before guessing at a fix.

Verified the heredoc escaping by rendering the write_readme block through
a real bash heredoc and confirming $WOLF_DIR interpolates while every
other $ stays literal in the output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLX1yYKJExGSXmgUhxKQG6
2026-09-03 19:23:20 +00:00
+641 -2
View File
@@ -285,6 +285,84 @@ print(pick[0]["browser_download_url"] if pick else "")
esac
}
# Same job as _wolf_download_emulator_appimage above, but against GitLab's
# Releases API instead of GitHub's — needed for any project (ES-DE included)
# that's hosted on GitLab rather than GitHub, since GitHub's API obviously
# can't answer for a repo it doesn't host. Mirrors the same arch-matching /
# post-download ELF-header verification logic so both call sites behave
# identically from the caller's point of view.
_wolf_download_emulator_appimage_gitlab() {
local _display_name="$1" _project_path="$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 GitLab..."
local _encoded_path
_encoded_path=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$_project_path")
local _url
_url=$(curl -fsSL "https://gitlab.com/api/v4/projects/${_encoded_path}/releases" \
| 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)
releases = json.load(sys.stdin)
# GitLab does not guarantee list order — sort explicitly instead of
# assuming index 0 is the newest (the mistake that would silently pick a
# stale/older release on some future API response ordering change).
releases = sorted(releases, key=lambda r: r.get("released_at") or "", reverse=True)
assets = []
for r in releases:
for link in r.get("assets", {}).get("links", []):
url = link.get("direct_asset_url") or link.get("url") or ""
if url.endswith(".AppImage"):
assets.append({"name": link.get("name", url), "browser_download_url": url})
if assets:
break
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://gitlab.com/${_project_path}/-/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://gitlab.com/${_project_path}/-/releases"; return 1; }
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://gitlab.com/${_project_path}/-/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://gitlab.com/${_project_path}/-/releases"
;;
esac
}
install_wolf() {
require_docker || return 1
@@ -939,6 +1017,85 @@ UDEV
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: ES-DE and RetroArch as standalone AppImages (for Steam) ────
# These are ADDITIONAL to the esde/retroarch Wolf catalog containers
# above, not a replacement — nothing here removes or changes those. The
# only reason to want this: once added as a Steam non-Steam game
# (./manage.sh steam-add-nonsteam-game, which the wolf mount fix above
# already extended to reach the same roms/saves/bios/retro-home/
# retroarch paths the esde/retroarch containers use), Steam Input can
# give each of up to 4 identical-model controllers its own distinct
# identity by device path — the one thing Wolf's own 3-concrete-pad-type
# ceiling can't do for a 4th controller (see manage.sh's own "4
# controllers (Cemu / Wii U games)" help text). Skip both prompts below
# if you're happy running Wii U/retro systems through the esde app
# directly and don't need Steam's per-device controller assignment.
#
# Both download to a FIXED, predictable filename (ES-DE.AppImage /
# RetroArch.AppImage) regardless of the real upstream release asset's
# own name — steam-add-nonsteam-game matches by substring against the
# actual filename on disk, and bash glob matching is case-sensitive, so
# a fixed name (same symlink trick already used for Dolphin above) is
# what makes './manage.sh steam-add-nonsteam-game es-de' reliably find
# it regardless of how the vendor's own release happens to be named.
if [ ! -f "$_EMU_DIR/ES-DE.AppImage" ]; then
echo ""
# ES-DE is hosted on GitLab, not GitHub (confirmed against its own
# project page) — a different Releases API than every other
# standalone emulator above, hence the separate _gitlab helper.
log_info "ES-DE also ships an official standalone Linux AppImage — separate from the esde Wolf"
log_info "app above. Only useful for adding to Steam (see this repo's wolf README); skip this if"
log_info "you'll only ever use the esde app directly."
local _GET_ESDE_APPIMAGE=""
prompt_yn "Download the ES-DE AppImage for use via Steam? (y/n):" "n" _GET_ESDE_APPIMAGE
if [[ "$_GET_ESDE_APPIMAGE" =~ ^[Yy]$ ]]; then
_wolf_download_emulator_appimage_gitlab \
"ES-DE" "es-de/emulationstation-de" "ES-DE.AppImage" "$_EMU_DIR"
local _ESDE_REAL
_ESDE_REAL=$(ls "$_EMU_DIR"/*.AppImage 2>/dev/null \
| grep -iE '/(es-?de|emulationstation)[^/]*\.AppImage$' \
| grep -v '/ES-DE\.AppImage$' | head -1)
if [[ -n "$_ESDE_REAL" ]]; then
ln -sf "$(basename "$_ESDE_REAL")" "$_EMU_DIR/ES-DE.AppImage"
chown -h "$ACTUAL_USER:$ACTUAL_USER" "$_EMU_DIR/ES-DE.AppImage" 2>/dev/null || true
log_success "Linked $_EMU_DIR/ES-DE.AppImage -> $(basename "$_ESDE_REAL")"
log_info "Once Wolf is running, add it to Steam with: cd $WOLF_DIR && ./manage.sh steam-setup-frontends"
log_info "(waits for Steam sign-in — QR code via Moonlight — then wires this in automatically)"
fi
fi
fi
if [ ! -f "$_EMU_DIR/RetroArch.AppImage" ]; then
echo ""
# Libretro's own buildbot doesn't publish through a GitHub/GitLab
# Releases API this installer can automate against — hizzlekizzle/
# RetroArch-AppImage is the well-regarded THIRD-PARTY nightly-build
# project the AppImage community catalogs (appimage.github.io etc.)
# themselves point to, same "flagged, not silently offered as
# official" treatment as the Dolphin community build above.
log_info "RetroArch also has a standalone Linux AppImage, separate from the retroarch Wolf app"
log_info "above — same rationale as ES-DE just above (Steam Input's per-device controller"
log_info "assignment). This comes from hizzlekizzle/RetroArch-AppImage, a well-regarded but"
log_info "THIRD-PARTY nightly-build project, not an official libretro.org release — grab"
log_info "RetroArch's own build by hand instead if you'd rather not run that."
local _GET_RA_APPIMAGE=""
prompt_yn "Download the RetroArch AppImage for use via Steam? (y/n):" "n" _GET_RA_APPIMAGE
if [[ "$_GET_RA_APPIMAGE" =~ ^[Yy]$ ]]; then
_wolf_download_emulator_appimage \
"RetroArch" "hizzlekizzle/RetroArch-AppImage" "RetroArch.AppImage" "$_EMU_DIR"
local _RA_REAL
_RA_REAL=$(ls "$_EMU_DIR"/*[Rr]etro[Aa]rch*.AppImage 2>/dev/null \
| grep -v '/RetroArch\.AppImage$' | head -1)
if [[ -n "$_RA_REAL" ]]; then
ln -sf "$(basename "$_RA_REAL")" "$_EMU_DIR/RetroArch.AppImage"
chown -h "$ACTUAL_USER:$ACTUAL_USER" "$_EMU_DIR/RetroArch.AppImage" 2>/dev/null || true
log_success "Linked $_EMU_DIR/RetroArch.AppImage -> $(basename "$_RA_REAL")"
log_info "Once Wolf is running, add it to Steam with: cd $WOLF_DIR && ./manage.sh steam-setup-frontends"
log_info "(waits for Steam sign-in — QR code via Moonlight — then wires this in automatically)"
fi
fi
fi
# ── 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
@@ -2166,7 +2323,26 @@ CATALOG = {
# (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'],
f'{games}/emulators:/home/retro/Applications:rw',
# Same roms/saves/bios/retro-home/retroarch mounts as esde/
# retroarch below — without these, a standalone ES-DE or
# RetroArch AppImage added here as a non-Steam game (same
# mechanism as Cemu above) would see none of the ROMs, cores,
# save states, BIOS files, or ES-DE's own settings/custom
# systems (TI-99, Wii U AntiMicroX command) that the esde/
# retroarch containers already have — it'd start from a
# completely empty config instead of reusing what's already
# set up. Every path here is the exact same host directory
# those two containers mount, just also visible from Steam.
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}/esde-custom-systems:/home/retro/ES-DE/custom_systems:rw',
f'{games}/esde-settings:/home/retro/ES-DE/settings: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'],
@@ -3554,7 +3730,7 @@ _manage_wolf_complete() {
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
steam-add-nonsteam-game"
steam-add-nonsteam-game steam-setup-frontends cemu-clone-controller cemu-sync-controllers"
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
}
# Register for both 'manage.sh' and './manage.sh' invocation styles
@@ -4014,6 +4190,334 @@ VDFPY
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."
;;
steam-setup-frontends)
# One command covering what the ES-DE/RetroArch AppImage download
# step in setup.sh can't finish on its own: that step runs before
# Wolf/Steam containers even exist, so it can only download the
# AppImages and print instructions. This picks up from there —
# start Steam if needed, WAIT for it to be signed in (Steam Guard's
# QR-code sign-in itself can't be scripted: it needs a phone
# approving a prompt, so this only polls for the result, never
# performs the sign-in), then add whichever AppImage(s) already got
# downloaded as Steam non-Steam games via the existing
# steam-add-nonsteam-game command above (re-invoked, not
# reimplemented, so the two never drift apart).
if ! docker ps --format '{{.Names}}' | grep -qi WolfSteam; then
echo "Starting Wolf (docker compose up -d)..."
docker compose up -d
sleep 5
fi
_SSF_SIGNED_IN() {
local _home
_home=$(_steam_home)
[ -n "$_home" ] && [ -n "$(sudo ls "$_home/.steam/steam/userdata/" 2>/dev/null)" ]
}
if _SSF_SIGNED_IN; then
echo "Steam is already signed in — proceeding."
else
echo ""
echo "Steam isn't signed in yet. In Moonlight:"
echo " 1. Connect to the Steam app."
echo " 2. On Steam's login screen, choose 'Sign in with QR code'."
echo " 3. Scan it with your phone's Steam app and approve the prompt."
echo ""
echo "Waiting for sign-in (up to 10 minutes, checking every 5s — Ctrl+C to give up"
echo "and finish this later by re-running './manage.sh steam-setup-frontends')..."
_SSF_WAITED=0
until _SSF_SIGNED_IN; do
sleep 5
_SSF_WAITED=$((_SSF_WAITED + 5))
if [ "$_SSF_WAITED" -ge 600 ]; then
echo "Still not signed in after 10 minutes — giving up for now."
exit 1
fi
done
echo "Signed in."
fi
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
_SSF_EMU_DIR="$GAME_DIR/emulators"
_SSF_ADDED_ANY=0
if [ -f "$_SSF_EMU_DIR/ES-DE.AppImage" ]; then
"$0" steam-add-nonsteam-game es-de "EmulationStation (ES-DE)" && _SSF_ADDED_ANY=1
else
echo "ES-DE.AppImage not found in $_SSF_EMU_DIR — download it first: sudo ./setup.sh wolf"
fi
if [ -f "$_SSF_EMU_DIR/RetroArch.AppImage" ]; then
"$0" steam-add-nonsteam-game retroarch "RetroArch" && _SSF_ADDED_ANY=1
else
echo "RetroArch.AppImage not found in $_SSF_EMU_DIR — download it first: sudo ./setup.sh wolf"
fi
if [ "$_SSF_ADDED_ANY" = 1 ]; then
echo ""
echo "Cores/shaders/overlays live in the same retroarch/ directory the esde/retroarch apps"
echo "already use (shared mount — nothing new to configure there). If you haven't already:"
echo " ./manage.sh cores all (downloads every libretro core + shaders/overlays/database)"
fi
;;
cemu-clone-controller)
# Clones a WORKING Cemu controller mapping onto a new device slot,
# skipping Cemu's own Input Settings dialog entirely for that slot.
#
# Why this is safe, not just convenient: confirmed against two real,
# separately-hand-mapped profiles captured live (Nintendo Switch Pro
# on controller0.xml, Xbox One S on controller1.xml) — both contain
# the exact same 24 <mapping>/<button> pairs, just listed in a
# different order. That's real proof (not the README's word for
# it) that Cemu's <mappings> block uses universal
# SDL_CONTROLLER_BUTTON_* semantics, not per-device raw indices, and
# that Cemu doesn't care about entry order — so a proven-working
# block can be dropped into a new device's file wholesale.
#
# This still needs ONE real, Cemu-written <uuid> per new device
# type first — that value isn't something this script can compute
# (it depends on the CRC16-of-device-name half of the SDL GUID,
# which SDL derives from the exact joystick name string the device
# reports at runtime — guessing it risks writing a uuid Cemu will
# never actually match against the live device). Get it the
# cheapest possible way: in Cemu's Input Settings for the new
# controller slot, click + and select the device — even mapping
# ONE button and hitting Save is enough, since only the <uuid> and
# <display_name> this writes are kept; the whole <mappings> block
# gets replaced by this command anyway.
if [ -z "${3:-}" ]; then
echo "Usage: ./manage.sh cemu-clone-controller <target-slot 0-3> <uuid> [display name]"
echo ""
echo " <target-slot> Which controllerN.xml to write (0-indexed, same as"
echo " Cemu's own file naming and Wolf's own controller"
echo " numbering — controller0.xml is Cemu's Controller 1)."
echo " <uuid> The REAL uuid Cemu wrote for the new device — get this"
echo " by adding it in Cemu's Input Settings once (map just one"
echo " button, Save), then:"
echo " cat controllerProfiles/controllerN.xml | grep uuid"
echo " [display name] Cosmetic only. Defaults to the source profile's own name."
exit 1
fi
_CCC_SLOT="${2:-}"
_CCC_UUID="${3:-}"
_CCC_NAME="${4:-}"
case "$_CCC_SLOT" in
0|1|2|3) ;;
*) echo "Target slot must be 0, 1, 2, or 3 (Cemu's own Controller 1-4, 0-indexed)."; exit 1 ;;
esac
case "$_CCC_UUID" in
0_????????????????????????????????) ;;
*) echo "That doesn't look like a real Cemu uuid (expected '0_' + 32 hex chars)."
echo "Got: $_CCC_UUID"
exit 1 ;;
esac
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
_CCC_DIR="$GAME_DIR/retro-home/Cemu/controllerProfiles"
if [ ! -d "$_CCC_DIR" ]; then
echo "No controllerProfiles/ yet at $_CCC_DIR — set up at least one controller"
echo "in Cemu's own Input Settings first (Controller 1), so there's a real,"
echo "working profile to clone the mapping from."
exit 1
fi
# Pick any existing, non-empty controllerN.xml as the mapping source —
# prefer controller0.xml (Cemu's own Controller 1), since that's the
# slot people set up first, but fall back to whichever one actually
# exists so this isn't hard-wired to slot 0 specifically.
_CCC_SRC=""
for _n in 0 1 2 3; do
_cand="$_CCC_DIR/controller${_n}.xml"
if [ -s "$_cand" ] && [ "$_n" != "$_CCC_SLOT" ]; then
_CCC_SRC="$_cand"
break
fi
done
if [ -z "$_CCC_SRC" ]; then
echo "No other working controllerN.xml found in $_CCC_DIR to clone from."
echo "Set up at least one controller fully in Cemu's own Input Settings first."
exit 1
fi
_CCC_TARGET="$_CCC_DIR/controller${_CCC_SLOT}.xml"
[ -f "$_CCC_TARGET" ] && cp "$_CCC_TARGET" "$_CCC_TARGET.bak.$(date +%s)"
if [ -z "$_CCC_NAME" ]; then
_CCC_NAME=$(grep -o '<display_name>[^<]*</display_name>' "$_CCC_SRC" | head -1 | sed -E 's#</?display_name>##g')
fi
python3 - "$_CCC_SRC" "$_CCC_TARGET" "$_CCC_UUID" "$_CCC_NAME" << 'CLONEPY'
import re, sys
src_path, out_path, uuid, display_name = sys.argv[1:5]
with open(src_path) as f:
template = f.read()
new = re.sub(r'<uuid>[^<]*</uuid>', f'<uuid>{uuid}</uuid>', template, count=1)
new = re.sub(r'<display_name>[^<]*</display_name>', f'<display_name>{display_name}</display_name>', new, count=1)
with open(out_path, 'w') as f:
f.write(new)
print(f"Wrote {out_path}")
print(f" uuid: {uuid}")
print(f" display_name: {display_name}")
CLONEPY
chown 1000:1000 "$_CCC_TARGET"
echo ""
echo "Cloned the mapping from $(basename "$_CCC_SRC") onto controller${_CCC_SLOT}.xml."
echo "Close Cemu COMPLETELY and relaunch for this to take effect — a hand-edited"
echo "profile file isn't picked up by an already-running Cemu."
;;
cemu-sync-controllers)
# Fully automates cemu-clone-controller above: queries SDL directly
# (the exact same library Cemu itself links against) for the REAL
# live GUID of every controller currently connected to the active
# session's app container, then clones a proven-working mapping
# onto each one's controllerN.xml — no Cemu GUI interaction at all,
# not even the single-button-and-Save step cemu-clone-controller
# still needs to learn a brand-new device's uuid.
#
# CONFIRMED LIVE: querying SDL_JoystickGetDeviceGUID() this way
# inside a running ES-DE session reproduced the EXACT uuids Cemu
# had already written by hand for two different real controllers
# (Nintendo Switch Pro and Xbox One S) — byte-for-byte identical to
# controllerProfiles/controller0.xml and controller1.xml. SDL's own
# library is the authoritative source for this value — an earlier
# attempt to reverse-engineer just the CRC16 portion of the GUID by
# hand, tried against 7 different CRC16 variants, matched neither
# device. This sidesteps that entirely by asking the same library
# Cemu itself calls, instead of recomputing what it would return.
#
# Wolf's own controller numbering (0-indexed) matched SDL's
# enumeration order for that session 1:1 in testing (index 0 was
# the controller Wolf logged as "controller 0", index 1 was
# "controller 1") — so the Nth joystick SDL reports maps directly
# onto controllerN.xml, the same file-naming convention used
# everywhere else in this script.
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
_CSC_DIR="$GAME_DIR/retro-home/Cemu/controllerProfiles"
if [ ! -d "$_CSC_DIR" ]; then
echo "No controllerProfiles/ yet at $_CSC_DIR — set up at least one controller"
echo "in Cemu's own Input Settings first (Controller 1), so there's a real,"
echo "working profile for this to clone the mapping from."
exit 1
fi
# Find whichever currently-running Wolf app container actually has
# joysticks wired up — the active session's controllers could be
# attached to ES-DE, Desktop, RetroArch, or Steam depending on what
# you're connected to right now, so this isn't hard-wired to one app.
_CSC_CONTAINER=""
for _c in $(docker ps --format '{{.Names}}' | grep -i '^Wolf'); do
if docker exec "$_c" test -e /dev/input/js0 2>/dev/null; then
_CSC_CONTAINER="$_c"
break
fi
done
if [ -z "$_CSC_CONTAINER" ]; then
echo "No running Wolf app container has any controller attached right now."
echo "Connect via Moonlight with your controllers active, then re-run this."
exit 1
fi
echo "Reading live controller GUIDs from $_CSC_CONTAINER..."
_CSC_PROBE=$(docker exec -i -u retro "$_CSC_CONTAINER" python3 - << 'PROBEPY'
import ctypes, ctypes.util, sys
libname = ctypes.util.find_library("SDL2") or "libSDL2-2.0.so.0"
try:
sdl = ctypes.CDLL(libname)
except OSError as e:
print(f"ERROR: could not load libSDL2 ({libname}): {e}", file=sys.stderr)
sys.exit(1)
class SDL_JoystickGUID(ctypes.Structure):
_fields_ = [("data", ctypes.c_uint8 * 16)]
sdl.SDL_Init.argtypes = [ctypes.c_uint32]
sdl.SDL_Init.restype = ctypes.c_int
sdl.SDL_NumJoysticks.restype = ctypes.c_int
sdl.SDL_JoystickGetDeviceGUID.argtypes = [ctypes.c_int]
sdl.SDL_JoystickGetDeviceGUID.restype = SDL_JoystickGUID
sdl.SDL_JoystickGetGUIDString.argtypes = [SDL_JoystickGUID, ctypes.c_char_p, ctypes.c_int]
sdl.SDL_JoystickNameForIndex.argtypes = [ctypes.c_int]
sdl.SDL_JoystickNameForIndex.restype = ctypes.c_char_p
if sdl.SDL_Init(0x00000200) != 0:
print("ERROR: SDL_Init(JOYSTICK) failed", file=sys.stderr)
sys.exit(1)
n = sdl.SDL_NumJoysticks()
for i in range(n):
guid = sdl.SDL_JoystickGetDeviceGUID(i)
buf = ctypes.create_string_buffer(64)
sdl.SDL_JoystickGetGUIDString(guid, buf, 64)
name = sdl.SDL_JoystickNameForIndex(i)
name = name.decode() if name else f"Controller {i}"
print(f"{i} 0_{buf.value.decode()} {name}")
PROBEPY
)
if [ -z "$_CSC_PROBE" ]; then
echo "SDL reported zero joysticks inside $_CSC_CONTAINER — nothing to sync."
echo "Make sure controllers are actually connected and active in this session."
exit 1
fi
echo "$_CSC_PROBE"
echo ""
_CSC_ANY_SYNCED=0
while read -r _idx _uuid _live_name; do
[ -z "$_idx" ] && continue
_target="$_CSC_DIR/controller${_idx}.xml"
# Already has this exact uuid — nothing to do, don't churn a backup.
if [ -f "$_target" ] && grep -qF "<uuid>$_uuid</uuid>" "$_target"; then
echo "controller${_idx}.xml already has this device's mapping — skipping."
continue
fi
# Pick any OTHER existing, non-empty controllerN.xml as the source.
_src=""
for _n in 0 1 2 3; do
_cand="$_CSC_DIR/controller${_n}.xml"
if [ -s "$_cand" ] && [ "$_n" != "$_idx" ]; then
_src="$_cand"
break
fi
done
if [ -z "$_src" ]; then
echo "controller${_idx}.xml: no other working profile to clone from yet — skipping."
continue
fi
[ -f "$_target" ] && cp "$_target" "$_target.bak.$(date +%s)"
# Use SDL's own live device name if it reported one — more useful
# in Cemu's own UI than inheriting whatever device the mapping
# template happened to come from.
_name="${_live_name:-$(grep -o '<display_name>[^<]*</display_name>' "$_src" | head -1 | sed -E 's#</?display_name>##g')}"
python3 - "$_src" "$_target" "$_uuid" "$_name" << 'SYNCPY'
import re, sys
src_path, out_path, uuid, display_name = sys.argv[1:5]
with open(src_path) as f:
template = f.read()
new = re.sub(r'<uuid>[^<]*</uuid>', f'<uuid>{uuid}</uuid>', template, count=1)
new = re.sub(r'<display_name>[^<]*</display_name>', f'<display_name>{display_name}</display_name>', new, count=1)
with open(out_path, 'w') as f:
f.write(new)
SYNCPY
chown 1000:1000 "$_target"
echo "controller${_idx}.xml: synced (uuid $_uuid, cloned from $(basename "$_src"))."
_CSC_ANY_SYNCED=1
done <<< "$_CSC_PROBE"
echo ""
if [ "$_CSC_ANY_SYNCED" = 1 ]; then
echo "Close Cemu COMPLETELY and relaunch for the synced profiles to take effect."
fi
;;
pin)
# Wolf logs: "Insert pin at http://SOMEIP:47989/pin/#HEXHASH"
# Extract just the hash fragment and build URLs for every interface
@@ -4068,6 +4572,11 @@ VDFPY
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 steam-setup-frontends - Wait for Steam sign-in (QR code via Moonlight), then add"
echo " downloaded ES-DE/RetroArch AppImages to Steam"
echo " ./manage.sh cemu-clone-controller [slot 0-3] [uuid] [display name]"
echo " - Clone a working Cemu controller mapping onto a new device slot"
echo " ./manage.sh cemu-sync-controllers - Auto-detect connected controllers via SDL and clone mappings onto all of them"
echo " ./manage.sh backup - How to set up backups"
;;
esac
@@ -4628,6 +5137,8 @@ cd $WOLF_DIR
./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
./manage.sh cemu-clone-controller # clone a working Cemu controller mapping onto a new device slot
./manage.sh cemu-sync-controllers # auto-detect connected controllers via SDL, clone mappings onto all of them
\`\`\`
### Tab completion
@@ -4665,6 +5176,69 @@ 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.
## Running ES-DE and/or RetroArch through Steam instead of their own Wolf apps
The same Steam Input reasoning above applies beyond just Cemu: ES-DE and
RetroArch both ship their own official standalone Linux AppImages
(separate from the \`esde\`/\`retroarch\` Wolf catalog apps this installer
already runs), and adding one of those to Steam gets you the same
per-device controller assignment for every system it covers, not just
Cemu. \`sudo ./setup.sh wolf\` offers to download both (opt-in, default
no) right after the Cemu step — ES-DE's from its GitLab releases (it
isn't on GitHub), RetroArch's from \`hizzlekizzle/RetroArch-AppImage\` (a
well-regarded third-party nightly build — libretro.org's own buildbot
doesn't publish through an API this installer can automate against).
Both land in \`emulators/\` under a fixed name (\`ES-DE.AppImage\` /
\`RetroArch.AppImage\`) regardless of the real release asset's own
filename, so Steam's non-Steam-game matching finds them reliably.
They reuse the exact same \`roms/\`, \`saves/\`, \`bios/\`, \`retro-home\`, and
\`retroarch\` (cores/shaders/overlays) directories the \`esde\`/\`retroarch\`
containers already use — the \`steam\` Wolf app mounts all of the same
paths, so nothing needs re-downloading or re-scraping just because it's
now also reachable from Steam. If you haven't already populated cores:
\`\`\`bash
cd $WOLF_DIR && ./manage.sh cores all
\`\`\`
Finishing the Steam side needs one thing that can't be scripted — Steam
Guard's QR-code sign-in requires a phone approving a prompt — so this
polls for it instead of trying to script past it:
\`\`\`bash
cd $WOLF_DIR && ./manage.sh steam-setup-frontends
\`\`\`
Starts Wolf if it isn't already up, checks whether Steam's already
signed in (proceeds immediately if so), otherwise prints the QR-code
steps and waits (up to 10 minutes) for sign-in to complete, then adds
whichever of ES-DE.AppImage/RetroArch.AppImage was downloaded as a Steam
non-Steam game — re-using \`steam-add-nonsteam-game\` above rather than
duplicating its shortcuts.vdf-writing logic. Safe to re-run any time
(e.g. if it timed out waiting, or you downloaded the second AppImage
later) — already-added shortcuts are updated in place, not duplicated.
**Returning to Steam from ES-DE, without a second instance:** if ES-DE
is running as a Steam non-Steam game (via the AppImage above, not the
separate \`esde\` Wolf app), Steam is the parent process the whole
time — quitting ES-DE drops you back into the same still-running Steam
Big Picture session rather than starting a new one. This only works for
the AppImage-in-Steam path; the standalone \`esde\` Wolf app is a
completely separate container, and Wolf has no supported way to hand off
from one running app to another mid-session (switching apps means
closing the Moonlight session and reconnecting to the other one).
**Multiple devices, same Steam account, same controller mappings?**
Worth knowing before relying on it: Wolf gives each *paired client* its
own separate Steam container/home directory (confirmed against this
repo's own \`_steam_home()\` — it searches across multiple
\`.../Steam\` directories, not just one), so a second device connecting
to Wolf doesn't reuse the first device's Steam install and has to sign
in separately the first time. Once it's signed into the *same* Steam
account, Valve's own account-level Steam Cloud config sync should
replicate your Steam Input controller bindings across those separate
local installs (the same mechanism that syncs bindings between a Steam
Deck and a gaming PC) — but that's a Steam-account feature, not
something Wolf or this installer controls, and hasn't been confirmed
live in this specific setup.
## 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
@@ -5065,6 +5639,33 @@ time Cemu launches from ES-DE too.
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.
**If the TV device stays stuck on "Disabled" and won't take a real
device at all** (not just a one-time hang), this narrows down whether
it's PulseAudio itself with nothing to offer inside the container, or
Cemu choking on a device it can actually see:
\`\`\`bash
# Has this been a full Wolf restart, not just a reconnect, since it broke?
sudo ./setup.sh wolf
# Does PulseAudio inside the Desktop container see any output devices at all?
CONTAINER=\$(docker ps --format '{{.Names}}' | grep -i WolfDesktop | head -1)
docker exec "\$CONTAINER" pactl list short sinks
docker exec "\$CONTAINER" pactl info | grep -i "default sink"
# Cemu's own log — it logs audio device init/errors directly
GAME_DIR=\$(grep '^GAME_STORAGE_DIR=' $WOLF_DIR/.env | cut -d= -f2-)
find "\$GAME_DIR/retro-home/Cemu" -iname "log.txt" -exec cat {} \\;
# What settings.xml currently has for Audio (even while stuck "disabled")
cat "\$GAME_DIR/retro-home/Cemu/settings.xml" 2>/dev/null | grep -A5 -i "<Audio"
\`\`\`
An empty \`pactl list short sinks\` means there's no real fix on Cemu's
side to chase — the container itself has no audio sink for anything to
attach to. A populated sink list with Cemu still stuck points at Cemu's
own device-switch path instead, and \`log.txt\` / the \`<Audio>\` block
above are what to paste back for that to get root-caused rather than
guessed at.
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\`).
@@ -5107,6 +5708,44 @@ ambiguity about which one Cemu picks. Back up the file first
\`sudo\` needed, these files are written world-writable by Cemu itself.
Close Cemu completely and relaunch for a hand-edited profile to take effect.
**\`./manage.sh cemu-clone-controller\` does this copy for you.** Once
you've got 4 distinct controller *types* set up via \`./manage.sh controllers\`
(so Cemu can tell the devices apart at all) and one slot fully mapped by
hand, adding the rest doesn't need full manual button-mapping — Cemu's own
\`<mappings>\` format is universal SDL_CONTROLLER_BUTTON_* semantics, not
per-device raw indices, so the same mapping works for every device. All
each new slot needs is its own real \`<uuid>\` — get that the cheap way (in
Cemu's Input Settings, click **+** on the new controller, select the
device, map ONE button, Save; the mapping itself is thrown away), then:
\`\`\`bash
cd $WOLF_DIR
grep uuid "\$(grep '^GAME_STORAGE_DIR=' .env | cut -d= -f2-)/retro-home/Cemu/controllerProfiles/controller2.xml"
./manage.sh cemu-clone-controller 2 0_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
\`\`\`
Clones a fully-configured slot's mapping onto slot 2 with that device's own
uuid — no per-button rebinding, no fighting the cut-off Save button for
every single controller. Backs up any existing \`controllerN.xml\` first,
and needs Cemu closed and relaunched afterward like any other hand-edit.
**\`./manage.sh cemu-sync-controllers\` skips the uuid step too — genuinely
zero Cemu GUI interaction for any controller.** Instead of getting each
new device's uuid from Cemu itself, this asks **SDL directly** — the exact
library Cemu links against — for the live GUID of every controller
currently connected to your active session, then clones the mapping onto
each one automatically. **Confirmed live:** querying SDL this way inside a
real session reproduced the *exact* uuids Cemu had already written by hand
for two different controllers, byte-for-byte. With your controller types
already assigned via \`./manage.sh controllers\` and at least one slot
mapped once (by hand, or via \`cemu-clone-controller\` above), just:
\`\`\`bash
cd $WOLF_DIR && ./manage.sh cemu-sync-controllers
\`\`\`
Run it any time after connecting with controllers active — already-synced
slots are detected and skipped (no needless backups), and it works
regardless of which app (ES-DE, Desktop, RetroArch, Steam) currently has
the controllers attached, since it looks for whichever running Wolf
container actually has joysticks wired up.
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.