Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 .srm and .state files next to the ROM. Saves belong in /home/retro.
  • The cores mount must be rw. Anything inside /home/retro/.config or /home/retro/ES-DE is subject to the init script’s chown -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 is tg16, Master System is mastersystem, SG-1000 is sg-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

ThingWhat happensFix
NVIDIA driver bumpopaque driver/library version mismatchrebuild nvidia-driver-vol (section 3)
TrueNAS upgrade/etc/udev/rules.d wiped, gamepads stop workingPost Init script reapplies it; kernel_extra_options survives on its own
Config edits vanishWolf rewrites config.toml on shutdownstop the stack before editing
App keeps old mountsapp containers are built at launch timequit and relaunch the app, restarting Wolf is not enough
ES-DE lists games, launches failROM dirs r without x for UID 1000chmod -R a+rX on the library
Nothing launches, no coresimage ships RetroArch without coressection 6.2
App container fails at boot:ro mount inside /home/retro/.config blocks chown -Rmake it :rw
Moonlight finds nothingUDP 5353 held by TrueNASadd host manually with the port
ix-apps fills upimage pulls failcheck 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.