Gemini_Generated_Image_itfa1qitfa1qitfa

Retro Gaming From the NAS: RGSX on TrueNAS, Batocera, and Sunshine Streaming on KDE Wayland

I wanted a proper retro gaming setup for the lounge: download games from a nice web interface, store everything on the NAS, play it all on a Batocera box under the TV, keep save files safe and backed up, and, as a bonus, stream my modern PC games from my desktop to the same TV. After an evening of tinkering it all works, and it works well. This post walks through the whole build, including every wall I hit along the way, because the gotchas are where the real value is.

The end result

  • RGSX runs as a custom app on TrueNAS SCALE and downloads ROMs and BIOS files straight into NAS datasets.
  • Batocera on a small lounge PC mounts the ROMs, BIOS and saves folders from the NAS over SMB. Nothing game-related lives on the lounge PC itself.
  • Saves are snapshotted and backed up off-site along with the rest of the NAS.
  • Sunshine on my CachyOS desktop (KDE Plasma on Wayland) streams Steam Big Picture to Moonlight on the Batocera box, with my ultrawide monitor switching to a 16:9 resolution automatically while streaming.

The lounge PC and the NAS sit on different VLANs, which adds a firewall step or two. I’ve included those too.

Part 1: RGSX on TrueNAS SCALE

RGSX is a ROM download manager built for Batocera, Knulli and RetroBat. Most people run it on the console itself, but the repo also ships a Docker setup that runs it headless with a web UI. That’s perfect for a NAS: downloads land directly on the storage, with no copying step.

Datasets

I created a parent dataset called Retro with three children, plus a config dataset for the app:

tank/Retro/ROMs      # games, one folder per platform
tank/Retro/BIOS      # BIOS files
tank/Retro/Saves     # Batocera save files and states
tank/Apps-Config/rgsx   # RGSX settings, history, API keys

I shared Retro over SMB and gave both the TrueNAS apps user (UID/GID 568) and my SMB user Full Control on the ROMs and BIOS datasets. Running the container as apps saved me from fighting parent-folder traverse permissions, because that user already owns the app config datasets.

The app YAML

TrueNAS SCALE 24.10 and later can run any Docker Compose file under Apps → Discover Apps → ⋮ → Install via YAML. RGSX has no published image, so the compose file builds it from the GitHub repo:

services:
  rgsx:
    build:
      context: https://github.com/RetroGameSets/RGSX.git#main
      dockerfile: docker/Dockerfile
    image: rgsx:local
    container_name: rgsx
    restart: unless-stopped
    ports:
      - "30500:5000"
    environment:
      - PUID=568
      - PGID=568
    volumes:
      - /mnt/tank/Apps-Config/rgsx:/config
      - /mnt/tank/Retro/ROMs:/data/roms
      - /mnt/tank/Retro/BIOS:/data/bios

The web UI is then at http://NAS-IP:30500. On first load, click Update games list and refresh. Until you do that, it shows “No platforms found”, which looks alarming but just means the lists haven’t been fetched yet.

Gotchas I hit

The app showed “Deploying”, then “Stopped”, with no containers and no logs. If the Workloads panel says “No containers are available”, the container was never created, which means the build failed. Check the job in Task Manager (the clipboard icon at the top right) for the reason. If your TrueNAS version won’t build from a build: block, build the image on another machine, push it to a registry (Docker Hub, or your own Forgejo/Gitea registry over HTTPS), and swap build: for image: youruser/rgsx:latest.

mkdir: cannot create directory '/config': Permission denied. The container was running as a UID that couldn’t get into the config dataset. Running as apps (568) fixed it.

BIOS downloads vanished. RGSX doesn’t put BIOS files in a ROMs platform folder. It extracts them into the data root, which is /data in Docker. If you only mount /data/roms, BIOS files end up inside the container and are lost on the next update. Mounting a dataset at /data/bios catches them, because the BIOS pack extracts into a bios/ folder.

Downloads failed at 95–100% with [Errno 1] Operation not permitted. This was the sneaky one. The file downloads fine, then RGSX runs chmod 644 on it, and that chmod is refused. Datasets created with TrueNAS’s SMB preset use the ZFS ACL mode Restricted, which rejects every chmod call, no matter how much access you grant in the ACL. The fix is to set the dataset’s ACL Mode to Discard under Edit → Advanced Options. Chmod calls then succeed silently without touching your ACL. Don’t use Passthrough: chmod 644 would rewrite the ACL and strip write access.

# If the ACL Mode dropdown is locked, one-off from System → Shell:
zfs set aclmode=discard tank/Retro/ROMs
zfs set aclmode=discard tank/Retro/BIOS

You’ll also see warnings about a missing rom_extensions.json and “no system found for /data/roms/…” in the logs. These are harmless in Docker: RGSX normally reads Batocera’s system list, which doesn’t exist inside the container.

Part 2: Batocera reading everything from the NAS

Batocera can mount individual userdata folders from network shares. RGSX already uses Batocera’s folder names (psx, snes, megadrive and so on), so the ROMs folder drops straight in.

Getting in over SSH

The user is root. Newer Batocera versions may not use the old default password. The current one is shown under Main Menu → System Settings → Security.

batocera-boot.conf

mount -o remount,rw /boot
cp /boot/batocera-boot.conf /boot/batocera-boot.conf.bak
nano /boot/batocera-boot.conf

Change sharedevice=INTERNAL to sharedevice=NETWORK, then add:

sharenetwork_smb1=ROMS@NAS-IP:Retro/ROMs:username=SMBUSER,password=SMBPASS,vers=3.0
sharenetwork_smb2=BIOS@NAS-IP:Retro/BIOS:username=SMBUSER,password=SMBPASS,vers=3.0
sharenetwork_smb3=SAVES@NAS-IP:Retro/Saves:username=SMBUSER,password=SMBPASS,vers=3.0
sharewait=30

The ROMS@, BIOS@ and SAVES@ prefixes mount only those folders, so the rest of Batocera’s config stays local. Use the NAS’s IP rather than its hostname, especially across VLANs, and avoid commas in the password because they break the line. After a reboot, check the mounts:

mount | grep cifs

You should see /userdata/roms, /userdata/bios and /userdata/saves. System Settings → Storage Device showing blank is normal with this setup.

Reading the mount errors

If nothing mounts, grep -i cifs /var/log/messages tells you why:

  • return code = -101 (network unreachable) in the first few seconds of boot is harmless. The network just isn’t up yet, and sharewait covers it.
  • return code = -13 (permission denied) means the login was rejected. In my case the user had dataset permissions but wasn’t on the SMB share ACL, which TrueNAS checks first. Test the login from another machine with smbclient //NAS-IP/Retro -U SMBUSER -m SMB3 -c 'ls' to separate a credentials problem from a permissions problem.

Crossing VLANs on a MikroTik

Batocera only needs TCP 445 to the NAS. One forward rule is enough, because the standard “accept established, related” rule handles the replies. In Winbox: IP → Firewall → Filter Rules → +, chain forward, source the Batocera box (give it a static DHCP lease first), destination the NAS, protocol tcp, dst-port 445, action accept. Then drag it above any inter-VLAN drop rules. To test it from Batocera:

timeout 3 bash -c '</dev/tcp/NAS-IP/445' && echo "SMB reachable" || echo "blocked"

Part 3: Multi-disc games

Batocera groups multi-disc games using an .m3u playlist. Per the Batocera wiki, each game can live in its own normal folder with the playlist inside:

psx/
└── Metal Gear Solid/
    ├── Metal Gear Solid.m3u
    ├── Metal Gear Solid (USA) (Disc 1) (Rev 1).chd
    └── Metal Gear Solid (USA) (Disc 2) (Rev 1).chd

The .m3u holds one bare filename per line. Use no ./ prefix: Batocera only hides the individual disc entries when the playlist uses plain filenames in the same folder. The easiest way to avoid copy-paste whitespace problems is to write the file over SSH:

printf '%s\n' "Metal Gear Solid (USA) (Disc 1) (Rev 1).chd" "Metal Gear Solid (USA) (Disc 2) (Rev 1).chd" \
  > "/userdata/roms/psx/Metal Gear Solid/Metal Gear Solid.m3u"
cat -A "/userdata/roms/psx/Metal Gear Solid/Metal Gear Solid.m3u"

That cat -A check is there because of my own mistake: my first playlist had three leading spaces on each line. The game showed a black screen and dropped straight back to the menu. /userdata/system/logs/es_launch_stderr.log gave it away. The emulator was looking for a file literally named " Metal Gear Solid…". Each line in cat -A output should start with the filename and end in $, with no ^M (Windows line endings) and no leading spaces.

Swapping discs in-game: Hotkey + B opens the RetroArch menu, then go to Disc Control.

Part 4: Scraping box art

TheGamesDB matches by name only, so oddly named files miss. ScreenScraper matches most ROMs by checksum, and a free account is plenty for a home library. Two tips:

  • Log in with your ScreenScraper username, not your email. The website accepts either, but the API only accepts the username, and the error you get (“Erreur de login : Vérifier les identifiants utilisateurs !”) is in French.
  • Long passwords are painful to type on an on-screen keyboard. Stop EmulationStation and paste them into the settings file over SSH instead:
/etc/init.d/S31emulationstation stop
nano /userdata/system/configs/emulationstation/es_settings.cfg
#   <string name="ScreenScraperUser" value="your_username" />
#   <string name="ScreenScraperPass" value="your_password" />
/etc/init.d/S31emulationstation start

Some sources also give files a prefix like Nintendo - Nintendo Entertainment System_Game Name.zip. Renaming them to just the game name helps the fallback name search and looks much cleaner in the list.

Part 5: Streaming PC games with Sunshine on KDE Wayland

I’d tried this before, given up, and blamed Wayland. It turns out the tooling has caught up. Sunshine v2026.516 added native KWin screencast capture, and Sunshine’s own docs now recommend kwin capture with vulkan encoding for KDE Plasma. No KMS hacks are needed.

Install and verify

First, confirm your GPU can hardware-encode:

sudo pacman -S --needed libva-utils
vainfo | grep -i enc     # look for VAEntrypointEncSlice on H264 / HEVC

CachyOS’s own repo already carried a version newer than 2026.516, so sudo pacman -S sunshine was enough. On plain Arch, LizardByte’s official pacman repo is the alternative. Then check that the package includes the KWin permission file. That .desktop entry is what authorises Sunshine to use KDE’s restricted screencast protocol:

pacman -Qi sunshine | grep Version
ls /usr/share/applications | grep -i sunshine   # needs ...Sunshine.kwin.desktop
getcap $(readlink -f /usr/bin/sunshine)         # cap_sys_admin,cap_sys_nice (KMS fallback)

sudo usermod -aG input $USER    # virtual gamepads; reboot afterwards
systemctl --user enable --now app-dev.lizardbyte.app.Sunshine

In the web UI at https://localhost:47990, the capture and encoder options are on the Advanced tab (not Audio/Video): set Force a Specific Capture Method to KWin Screencast and Force a Specific Encoder to Vulkan. A healthy log shows Found H.264 encoder: h264_vulkan [vulkan] and the same for HEVC and AV1.

If KWin capture ever misbehaves, Sunshine documents two fallbacks: portal capture, pre-approved with flatpak permission-set kde-authorized remote-desktop dev.lizardbyte.app.Sunshine yes (this works for non-Flatpak installs too), or kms, which the package’s capabilities already allow.

An ultrawide host and a 16:9 TV

Streaming a 3440×1440 desktop to a 16:9 TV gives you a short, letterboxed picture. Sunshine’s Command Preparations fix that by switching the monitor’s mode for the duration of the stream using kscreen-doctor. First find your output name and modes:

kscreen-doctor -o
# Output name is right after "Output: N", e.g. DP-2
# The current mode is marked with *, e.g. 3440x1440@165

Test both commands by hand, because a failing Do command blocks the stream from launching. Then add them under Configuration → General → Command Preparations:

StepCommand
Dokscreen-doctor output.DP-2.mode.1920x1080@60
Undokscreen-doctor output.DP-2.mode.3440x1440@165

With two monitors, also set Audio/Video → Display Id to the ultrawide’s output so Sunshine captures the right screen. Games open on the primary display, so streaming the primary one avoids “the game opened on the other monitor” surprises.

Firewall

Moonlight needs these ports from the Batocera box to the PC:

ProtocolPortsPurpose
TCP47984, 47989, 48010Pairing, HTTPS, RTSP
UDP47998–48000Video, control, audio

On the MikroTik, that’s two more forward rules alongside the SMB one. On the PC, my previous attempt had left ufw rules in place, including 47990/tcp (the Sunshine admin page) open to anyone. You only ever use that page via localhost, so close it:

sudo ufw delete allow 47990/tcp

Moonlight on Batocera

On Batocera v43, Moonlight is the full Moonlight Qt client. Enable it with:

mkdir -p /userdata/roms/moonlight
touch /userdata/roms/moonlight/Moonlight.moonlight

Update gamelists and launch it. Across VLANs, the PC won’t be auto-discovered (mDNS doesn’t cross subnets), so use Add PC manually with its IP, then enter the PIN in Sunshine’s PIN tab. Streaming 1080p60 HEVC at around 20 Mbps works well. The lounge PC’s old Intel iGPU decodes HEVC in hardware, but not AV1, so avoid AV1 on older clients.

Steam Big Picture

This is Sunshine’s documented Linux app setup. Steam restarts itself on launch, so it has to be a detached command:

FieldValue
Application NameSteam Big Picture
Detached Commandssetsid steam steam://open/bigpicture
Command Preparations → Undosetsid steam steam://close/bigpicture
Imagesteam.png

Disconnect is not quit

This one caught me out. In Moonlight, L1 + R1 + Start + Select disconnects but leaves the app running on the host, so the Undo commands never run and the monitor stays at 1080p. When I then picked a different app, Moonlight started crashing every time I selected the PC. The fixes:

  • Turn on “Quit app on host PC after ending stream” in Moonlight’s settings. The controller combo then ends the session properly, and the resolution switches back.
  • If a session gets stuck, use Troubleshooting → Force Close in Sunshine’s web UI (this runs the Undo too), then Restart.
  • As a last resort, run the Undo kscreen-doctor command by hand.

Odds and ends

  • Protect the saves. Retro/Saves is now the only copy of every save file. I snapshot the whole Retro dataset daily and include Saves in my off-site cloud sync. ROMs can be re-downloaded; saves can’t.
  • Back up batocera-boot.conf to the NAS so a reinstall takes two minutes: cp /boot/batocera-boot.conf /userdata/saves/.
  • PS1 upscaling on weak hardware: switching to SwanStation’s hardware renderer on an Intel HD 530 gave me audio with a black screen. PCSX-ReARMed (the default) with its own enhanced-resolution option is the safer route on an old iGPU.
  • Restart time: RGSX’s Docker entrypoint runs a recursive ownership change over /data on every start. That’s harmless with a small library, but expect slower app restarts as it grows.

Wrapping up

The finished workflow is lovely: pick a game in RGSX’s web UI from my phone, it lands on the NAS, I hit “Update gamelists” on the TV, and it’s there with box art. Saves follow the games, and the NAS backs them up. When I want something modern, Steam Big Picture is one menu away. Most of the friction along the way came from permissions (ZFS ACL modes, share ACLs, parent traverse) and from small details like whitespace in a playlist, so if you build something similar, those are the places to look first.

References

Add a Comment

You must be logged in to post a comment