Wolf (Games on Whales) on tnt-hex
Retro game streaming from TrueNAS SCALE to Moonlight clients, sharing the RTX 4060 Ti with existing AI containers.
As built, July 2026. Server 192.168.50.203, driver 570.172.08.
0. How Wolf works
Wolf is one long-lived container. It does not contain the emulators. When a Moonlight client picks an app, Wolf talks to /var/run/docker.sock and spins up a sibling container on the fly, streams its virtual Wayland desktop, and tears it down on disconnect.
Consequences worth internalising:
- Wolf needs the Docker socket, which is root-equivalent on tnt-hex.
- App containers appear in the TrueNAS UI, come and go, and carry a session UUID in their name. Anything not written through a declared mount is lost when the session ends.
- Paths in Wolf’s config are resolved to host paths, because Wolf passes them to the Docker API.
1. Layout
/mnt/tnt-hex/configs/wolf host-init.sh, udev rules
/mnt/tnt-hex/data/wolf Wolf state: config.toml, certs, per-app home dirs
/mnt/tnt-hex/data/retroarch libretro cores
/mnt/tnt-hex/media/roms RomM library, <platform>/roms/
configs is small hand-edited text. data/wolf grows: under profile-data/ it accumulates each app’s /home/retro, so emulator saves, states, and scraped media. Its own dataset is worth it.
data/wolf/cfg/ holds Wolf’s certificate and paired-client list. That is the directory to back up if you do not want to re-pair every client.
Permissions
App containers run as UID/GID 1000:1000, while the media dataset is owned by apps (568). The library shipped as drwxrw-r--: readable but not traversable by 1000.
The symptom is easy to misread, because ES-DE lists every filename correctly and then fails on all of them:
Error: FileSystemUtil::isDirectory(): filesystem error: status: Permission denied [/ROMs/gbc/...]
Read on a directory returns names; execute is what lets you look up the inodes behind them.
sudo chmod -R a+rX /mnt/tnt-hex/media/roms
Capital X adds execute to directories only, so ROM files do not become executable, and + only adds, so RomM keeps its access. This works because the dataset is acltype=nfsv4 with aclmode=passthrough, so chmod writes through to the ACL. With restricted or discard you would need the TrueNAS ACL editor instead.
Verify as the container user, not as root, since root reads straight through:
ESDE=$(sudo docker ps --format '{{.Names}}' | grep '^WolfES-DE')
sudo docker exec -u 1000 "$ESDE" stat /ROMs/gbc | head -4
The profile directory needs ownership rather than permissions:
sudo chown -R 1000:1000 /mnt/tnt-hex/data/wolf /mnt/tnt-hex/data/retroarch
2. Host prep
2.1 Render node
ls -l /sys/class/drm/renderD*/device/driver
tnt-hex returns a single node, renderD128 -> .../drivers/nvidia. With one GPU, WOLF_RENDER_NODE can stay at its default. With two, set it explicitly to the NVIDIA node, since Wolf’s zero-copy pipeline needs the Wayland rendering and the GStreamer encoding on the same device.
2.2 DRM modeset
Wolf needs this on NVIDIA for the headless compositor:
sudo cat /sys/module/nvidia_drm/parameters/modeset
It was N. Do not edit /etc/default/grub, which does not survive TrueNAS upgrades. The middleware stores the value in the config database and reapplies it on every boot:
sudo midclt call system.advanced.config | jq -r .kernel_extra_options # check first, this replaces
sudo midclt call system.advanced.update '{"kernel_extra_options": "nvidia-drm.modeset=1"}'
Reboot, confirm it reads Y. Modeset affects the DRM/KMS interface only, not CUDA, so existing AI containers are unaffected.
2.3 udev rules
/mnt/tnt-hex/configs/wolf/85-wolf-virtual-inputs.rules:
# Allows Wolf to access /dev/uinput (joypad support)
KERNEL=="uinput", SUBSYSTEM=="misc", MODE="0660", GROUP="input", OPTIONS+="static_node=uinput", TAG+="uaccess"
# Allows Wolf to access /dev/uhid (DualSense emulation)
KERNEL=="uhid", GROUP="input", MODE="0660", TAG+="uaccess"
# Keep the host's logind from claiming the virtual pads
KERNEL=="hidraw*", ATTRS{name}=="Wolf PS5 (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
SUBSYSTEMS=="input", ATTRS{name}=="Wolf X-Box One (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
SUBSYSTEMS=="input", ATTRS{name}=="Wolf PS5 (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
SUBSYSTEMS=="input", ATTRS{name}=="Wolf gamepad (virtual) motion sensors", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
SUBSYSTEMS=="input", ATTRS{name}=="Wolf Nintendo (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
2.4 Post Init script
TrueNAS rebuilds /etc on upgrade and NVIDIA device nodes are created lazily, so both get reapplied at boot. See host-init.sh alongside this document.
chmod +x /mnt/tnt-hex/configs/wolf/host-init.sh
sudo /mnt/tnt-hex/configs/wolf/host-init.sh
Register under System Settings > Advanced > Init/Shutdown Scripts > Add:
- Description:
Wolf: load uinput/uhid modules + install virtual gamepad udev rules - Type:
Script, Script:/mnt/tnt-hex/configs/wolf/host-init.sh - When:
Post Init, Timeout:30
The nvidia-modprobe step matters more than it looks. Device nodes are normally created by the first GPU container to start, but Wolf’s compose lists them explicitly and Docker refuses to create a container referencing a node that does not exist yet.
Check it survived a reboot with journalctl -t wolf-init.
3. NVIDIA driver volume
Upstream recommends this over the container toolkit, because Wolf injects drivers into the child containers it spawns and --gpus=all is unreliable there.
mkdir -p /tmp/gow-build && cd /tmp/gow-build
curl -fsSL https://raw.githubusercontent.com/games-on-whales/gow/master/images/nvidia-driver/Dockerfile \
| sudo docker build -t gow/nvidia-driver:latest -f - \
--build-arg NV_VERSION=$(cat /sys/module/nvidia/version) .
sudo docker create --rm --mount source=nvidia-driver-vol,destination=/usr/nvidia \
gow/nvidia-driver:latest sh
sudo docker run --rm -v nvidia-driver-vol:/usr/nvidia alpine ls /usr/nvidia/lib | head
The container is never started; Docker seeds a new named volume from the image at creation time. That last command is the check that matters, since an empty volume fails silently at encode time rather than at deploy. You want libnvidia-*.so.570.172.08.
This is the recurring maintenance item. After any TrueNAS driver bump: docker volume rm nvidia-driver-vol, then redo both commands. The symptom otherwise is an opaque driver/library version mismatch.
4. The Dockge stack
.env
DATA_PATH=/mnt/tnt-hex/data
WOLF_HTTP_PORT=8005
compose.yaml
services:
wolf:
image: ghcr.io/games-on-whales/wolf:stable
container_name: wolf
restart: unless-stopped
network_mode: host
environment:
- NVIDIA_DRIVER_VOLUME_NAME=nvidia-driver-vol
# Also sets where Wolf looks for config INSIDE the container, so the bind
# below must use the same path on both sides.
- HOST_APPS_STATE_FOLDER=${DATA_PATH}/wolf
- WOLF_HTTP_PORT=${WOLF_HTTP_PORT}
volumes:
# Identical on both sides, deliberately. Wolf inspects its own container's
# mounts to translate container paths into host paths when spawning app
# containers. A destination of /etc/wolf with a different source breaks that.
- ${DATA_PATH}/wolf:${DATA_PATH}/wolf:rw
- /var/run/docker.sock:/var/run/docker.sock:rw
- /dev/:/dev/:rw
- /run/udev:/run/udev:rw
- nvidia-driver-vol:/usr/nvidia:rw
devices:
- /dev/dri
- /dev/uinput
- /dev/uhid
- /dev/nvidia-uvm
- /dev/nvidia-uvm-tools
- /dev/nvidiactl
- /dev/nvidia0
- /dev/nvidia-modeset
- /dev/nvidia-caps/nvidia-cap1
- /dev/nvidia-caps/nvidia-cap2
device_cgroup_rules:
- 'c 13:* rmw'
labels:
- homepage.group=Games
- homepage.icon=moonlight
- homepage.name=Wolf
- homepage.description=Game streaming host
- homepage.href=http://192.168.50.203:8005
- diun.enable=true
volumes:
nvidia-driver-vol:
external: true
Devices, volumes, and device_cgroup_rules match the upstream NVIDIA example exactly. WOLF_STOP_CONTAINER_ON_EXIT, WOLF_LOG_LEVEL, and WOLF_RENDER_NODE are all left at their defaults; set WOLF_LOG_LEVEL=DEBUG temporarily when diagnosing a launch failure.
Ports. Wolf supports six overrides: WOLF_HTTP_PORT (47989), WOLF_HTTPS_PORT (47984), WOLF_CONTROL_PORT (47999), WOLF_RTSP_SETUP_PORT (48010), WOLF_VIDEO_PING_PORT (48100), WOLF_AUDIO_PING_PORT (48200). Only HTTP is moved, since it is the one reached from a browser. The rest are negotiated during the session and renumbering them buys nothing.
Healthy startup log
INFO | Using h264 encoder: nvcodec
INFO | Using h265 encoder: nvcodec
INFO | Using av1 encoder: nvcodec
INFO | Using zero copy pipeline on Nvidia (/dev/dri/renderD128)
INFO | HTTP server listening on port: 8005
INFO | HTTPS server listening on port: 47984
Ignorable warnings: vaInitialize: unknown libva error and Cannot create a VA display (GStreamer probing VA-API and QuickSync, irrelevant on NVIDIA); libgstvalidatessim.so failed to initialise; nvrtcGetCUBINSize: undefined symbol (runtime CUDA compilation, not needed by NVENC); Unable to become drm master (expected unprivileged mode); GStreamer queue and latency warnings. Adding user 'root' to groups: gow-gid-105 is a good sign, that is the input group.
Errors that matter: Unable to find docker mount for path means the bind source and destination do not match. mDNS error: Failed to open any client sockets means UDP 5353 is already held by TrueNAS, so auto-discovery will not work.
Confirm state landed on the pool rather than in the container’s writable layer:
ls -l /mnt/tnt-hex/data/wolf/cfg/ # want config.toml, cert.pem, key.pem
5. Pairing
mDNS is unavailable on this host, so add the host manually as 192.168.50.203:8005 in Moonlight.
Wolf logs a URL like http://192.168.50.203:8005/pin/#ABCD1234. Open it, enter the PIN. Moonlight then lists apps, with Wolf UI as the default entry point.
First launch of any app shows a black screen with a cursor while Wolf pulls a multi-gigabyte image. Watch the Wolf logs, not the stream.
6. ES-DE
Wolf writes /mnt/tnt-hex/data/wolf/cfg/config.toml on first start, ships an ES-DE entry with mounts = [], and that is why it initially shows nothing but Custom Scripts. ES-DE only displays systems containing at least one ROM file.
Two rules govern every edit here:
- The file is read only at startup, and Wolf rewrites it from memory on shutdown. Stop the stack before editing or your changes get clobbered.
- App containers are built at launch time. After restarting Wolf you must also quit and relaunch the app from Moonlight.
6.1 Mounts
ES-DE’s ROM directory is /ROMs, absolute, set in the image’s baked-in es_settings.xml. Not /home/retro/ROMs.
The library nests games at <platform>/roms/ and several folder names do not match ES-DE’s system names, so one bind per system does both jobs at once, with the destination doing the renaming.
Find the block whose runner name is WolfES-DE and set its mounts:
mounts = [
'/mnt/tnt-hex/media/roms/snes/roms:/ROMs/snes:ro',
'/mnt/tnt-hex/media/roms/gba/roms:/ROMs/gba:ro',
'/mnt/tnt-hex/media/roms/nes/roms:/ROMs/nes:ro',
'/mnt/tnt-hex/media/roms/segaMD/roms:/ROMs/megadrive:ro',
'/mnt/tnt-hex/media/roms/gb/roms:/ROMs/gb:ro',
'/mnt/tnt-hex/media/roms/gbc/roms:/ROMs/gbc:ro',
'/mnt/tnt-hex/media/roms/atari2600/roms:/ROMs/atari2600:ro',
'/mnt/tnt-hex/media/roms/n64/roms:/ROMs/n64:ro',
'/mnt/tnt-hex/media/roms/segaMS/roms:/ROMs/mastersystem:ro',
'/mnt/tnt-hex/media/roms/segaGG/roms:/ROMs/gamegear:ro',
'/mnt/tnt-hex/media/roms/colecovision/roms:/ROMs/colecovision:ro',
'/mnt/tnt-hex/media/roms/segaSG/roms:/ROMs/sg-1000:ro',
'/mnt/tnt-hex/media/roms/atari5200/roms:/ROMs/atari5200:ro',
'/mnt/tnt-hex/media/roms/atari7800/roms:/ROMs/atari7800:ro',
'/mnt/tnt-hex/media/roms/sega32x/roms:/ROMs/sega32x:ro',
'/mnt/tnt-hex/media/roms/psx/roms:/ROMs/psx:ro',
'/mnt/tnt-hex/media/roms/vb/roms:/ROMs/virtualboy:ro',
'/mnt/tnt-hex/media/roms/ngc/roms:/ROMs/gc:ro',
'/mnt/tnt-hex/media/roms/wii/roms:/ROMs/wii:ro',
'/mnt/tnt-hex/media/roms/nds/roms:/ROMs/nds:ro',
'/mnt/tnt-hex/media/roms/psp/roms:/ROMs/psp:ro',
'/mnt/tnt-hex/media/roms/ps2/roms:/ROMs/ps2:ro',
'/mnt/tnt-hex/data/retroarch/cores:/home/retro/.config/retroarch/cores:rw',
]
- Read-only for ROMs. RomM owns that tree, ES-DE has a delete-game function, and emulators drop
.srmand.statefiles next to the ROM. Saves belong in/home/retro. - The cores mount must be
rw. Anything inside/home/retro/.configor/home/retro/ES-DEis subject to the init script’schown -R, and a read-only mount there makes the container fail at boot. - Only populated systems listed. The library’s other folders are empty.
- Single quotes match what Wolf writes back. TOML literal strings, so nothing needs escaping.
- ES-DE 3.x system names catch people out: GameCube is
gc, PC Engine istg16, Master System ismastersystem, SG-1000 issg-1000.
Verify after relaunching:
ESDE=$(sudo docker ps --format '{{.Names}}' | grep '^WolfES-DE')
sudo docker exec "$ESDE" ls /ROMs/
6.2 Cores
The es-de image ships RetroArch with no cores. This is by design; the upstream pattern is mounting them from the host, which needs a host RetroArch that TrueNAS does not have.
sudo mkdir -p /mnt/tnt-hex/data/retroarch/cores
cd /tmp
BB=https://buildbot.libretro.com/nightly/linux/x86_64/latest
for c in snes9x nestopia gambatte mgba genesis_plus_gx picodrive \
mupen64plus_next stella prosystem gearcoleco swanstation dolphin; do
curl -fsSL "$BB/${c}_libretro.so.zip" -o "$c.zip" \
&& sudo python3 -c "import zipfile;zipfile.ZipFile('$c.zip').extractall('/mnt/tnt-hex/data/retroarch/cores')" \
&& echo "ok: $c"
done
sudo chown -R 1000:1000 /mnt/tnt-hex/data/retroarch
Those twelve cover every populated system. ES-DE picks up new cores at launch time, so no restart is needed for additions.
If a launch fails with emulator core file "X_libretro.so" not found, fetch that specific core, or set an alternative in ES-DE: highlight the system, Options, Alternative Emulators.
Diagnosis lives in ES-DE’s own log, not Wolf’s. Wolf only sees the session lifecycle:
sudo docker exec "$ESDE" grep -A6 "Couldn't launch\|Launching game" \
/home/retro/ES-DE/logs/es_log.txt | tail -30
6.3 Keyboard layout
Add to the app’s env array:
'XKB_DEFAULT_LAYOUT=be'
7. Clients
moonlight-qt is in Arch’s extra repo:
sudo pacman -S moonlight-qt libva-utils
vainfo # want VAProfileH264* and VAProfileHEVCMain from radeonsi
Avoid moonlight-qt-bin from the AUR, which repackages the AppImage and breaks hardware decode by linking against an older VA-API than Arch ships. The Flathub build is upstream’s own channel and bundles its own ffmpeg, which is a fair argument on a rolling distro; the repo package is currently at parity.
Add the host manually as 192.168.50.203:8005.
Settings: HEVC on (the 4060 Ti’s NVENC handles it and halves bitrate for the same quality), 60fps, 20-30 Mbps wired. Leave “Optimize game settings” off, it is a GameStream-era feature Wolf does not use.
Moonlight captures the keyboard while streaming. Ctrl+Alt+Shift+Q quits, Ctrl+Alt+Shift+Z toggles mouse capture.
8. Maintenance and gotchas
| Thing | What happens | Fix |
|---|---|---|
| NVIDIA driver bump | opaque driver/library version mismatch | rebuild nvidia-driver-vol (section 3) |
| TrueNAS upgrade | /etc/udev/rules.d wiped, gamepads stop working | Post Init script reapplies it; kernel_extra_options survives on its own |
| Config edits vanish | Wolf rewrites config.toml on shutdown | stop the stack before editing |
| App keeps old mounts | app containers are built at launch time | quit and relaunch the app, restarting Wolf is not enough |
| ES-DE lists games, launches fail | ROM dirs r without x for UID 1000 | chmod -R a+rX on the library |
| Nothing launches, no cores | image ships RetroArch without cores | section 6.2 |
| App container fails at boot | :ro mount inside /home/retro/.config blocks chown -R | make it :rw |
| Moonlight finds nothing | UDP 5353 held by TrueNAS | add host manually with the port |
ix-apps fills up | image pulls fail | check space before adding large apps |
Snapshot the working state:
sudo zfs snapshot tnt-hex/data@wolf-working
Still untested: whether virtual gamepads reach the emulator end to end, and whether the Post Init script fires unattended on a cold boot.
References
- Wolf quickstart: https://games-on-whales.github.io/wolf/stable/user/quickstart.html
- Wolf configuration: https://games-on-whales.github.io/wolf/stable/user/configuration.html
- App images (Wildlife): https://games-on-whales.github.io/wildlife/
Each Wildlife app page carries a Giscus comment thread below the default config block, and that is where the operational detail lives: real mount layouts, which paths need rw, where scraped media goes. The page bodies are just a screenshot and the stock config.toml. Comments do not appear in fetched or archived copies, so read them in a browser.