Orange Pi 5 Plus Emulation Setup: ES-DE, RetroArch, Cores and ROM Organisation
This post covers the emulation stack. The underlying SBC setup — power supply issues, governor tuning, and the VNC/SSH remote access infrastructure — is covered in the companion post: Orange Pi 5 Plus: Diagnosing Hard Resets and Setting Up Remote Access.
Chapter 1: Frontend — ES-DE #
Why not EmulationStation? #
RetroPie ships with its own EmulationStation build, but it fails to compile on Ubuntu
26.04 with GCC 15. The C++ standard changed and the old ES source never declared
CMAKE_CXX_STANDARD 11, causing Boost headers to bail out with a hard error. ES-DE
(EmulationStation Desktop Edition) is the actively maintained fork and ships pre-built
aarch64 AppImages — no compile step needed.
Installation #
Downloaded ES-DE 3.4.1 as an aarch64 AppImage (~127 MB). Placed it at
~/ES-DE_aarch64.AppImage and replaced the broken RetroPie autostart entry with an
ES-DE autostart at ~/.config/autostart/es-de.desktop.
All ES-DE launches go through ~/es-de-launch.sh (detailed below) so that env vars
and the VNC wait logic are applied consistently whether launching from the autostart,
the app menu, or a terminal.
GL context fix: GLXBadFBConfig #
ES-DE crashed immediately on first launch:
Error creating OpenGL context. Could not create GL context: GLXBadFBConfig
ES-DE requires an OpenGL 3.3 core profile context. Mesa’s Panfrost driver on the Mali-G610 only advertises OpenGL 3.1 by default. Fix: tell Panfrost to claim 3.3 via environment variables set in the launch wrapper.
Wayland context freeze #
Once ES-DE was running, launching a game would freeze after about 10 seconds with a clean exit and no error. Enabling RetroArch logging revealed:
GL context: "wayland"
Failed to specify monitor for fullscreen, letting compositor decide
Found display server: "null"
RetroArch was grabbing a Wayland EGL context even though the session runs on X11. The
Wayland compositor dropped the fullscreen window after a few seconds. Unsetting
WAYLAND_DISPLAY before launching forces RetroArch to fall back to an X11 EGL context.
After this the log showed GL context: egl_x and display server: x11 — games ran
without freezing.
ES-DE resolution sync with VNC #
When running remotely, ES-DE autostarted before the VNC client connected and pushed its
monitor resolution. It would lock to the session’s initial geometry and overflow the
screen. The fix: poll until an active VNC connection is detected on port 5902, then run
xrandr --auto to pick up the client’s resolution, then launch ES-DE.
The full launch wrapper #
~/es-de-launch.sh combining all three fixes:
#!/bin/bash
export MESA_GL_VERSION_OVERRIDE=3.3
export MESA_GLSL_VERSION_OVERRIDE=330
unset WAYLAND_DISPLAY
# On the VNC display (:2), wait for the client to connect and push its resolution
if [ "$DISPLAY" = ":2" ]; then
until ss -tnp | grep -q "ESTAB.*:5902"; do
sleep 1
done
xrandr --auto
fi
~/ES-DE_aarch64.AppImage
On a physical display (:0) the if block is skipped and ES-DE starts immediately.
Chapter 2: RetroArch Tuning #
All cores on this build are software-rendered: SNES, NES, GBA, Genesis, PS1, Game Boy family, FBNeo, MAME, PC Engine, Atari. No core uses hardware rendering internally, so every change below is safe across the entire library.
Config: ~/.config/retroarch/retroarch.cfg
Video driver: GL → Vulkan #
video_driver = "vulkan"
For software-rendered cores the GPU only blits the final frame to screen. The Mali-G610 has both Panfrost (OpenGL) and panvk (Vulkan) Mesa drivers. Vulkan’s lower CPU overhead means more A76 time goes to the emulator rather than the display driver.
Threaded video #
video_threaded = "true"
Decouples the render thread from the emulation thread. On a big.LITTLE SoC this lets the emulator and renderer run on separate A76 cores rather than sharing one.
Swapchain depth: 3 → 2 #
video_max_swapchain_images = "2"
Removes one frame of queue depth. The board handles it without tearing.
Frame delay and hard sync #
video_frame_delay_auto = "true"
video_hard_sync = "true"
video_hard_sync forces the CPU to wait for the GPU to finish the previous frame before
advancing — eliminating a source of variable lag. video_frame_delay_auto auto-tunes
the submission timing to squeeze as close to the vsync deadline as possible.
Audio latency: 64 ms → 32 ms #
audio_latency = "32"
Halves the audio buffer. The ALSA stack handles it without underruns.
Run-ahead (1 frame) #
run_ahead_enabled = "true"
run_ahead_frames = "1"
run_ahead_secondary_instance = "true"
Run-ahead speculatively emulates one frame ahead, rewinds, then sends input to the correct frame. Net effect: ~16 ms input lag reduction at 60 Hz. The secondary instance method is more accurate than the save-state method and compatible with all cores here.
Chapter 3: CPU Affinity Pinning #
The A76 big cores (cpu4–7) at 2.4 GHz handle all emulation. The A55 little cores (cpu0–3) handle the OS and background services. Pinning RetroArch to the big cluster prevents the kernel from migrating a hot emulation thread to a slow little core mid-frame.
/usr/local/bin/retroarch is a wrapper (was previously a symlink):
#!/bin/bash
exec taskset -c 4-7 /opt/retropie/emulators/retroarch/bin/retroarch "$@"
To revert to a plain symlink:
ln -sf /opt/retropie/emulators/retroarch/bin/retroarch /usr/local/bin/retroarch
Chapter 4: Xbox Controller #
The Xbox One controller paired over Bluetooth and was visible to the OS via jstest-gtk,
but RetroArch showed “Xbox Wireless Controller not configured.”
RetroArch looks for joypad autoconfig profiles in ~/.config/retroarch/autoconfig/.
That directory did not exist. The profiles that ship with RetroPie live elsewhere. The
fix was pointing joypad_autoconfig_dir in retroarch.cfg at the RetroPie autoconfig
directory. On the next launch RetroArch found the Xbox profile and auto-configured the
controller without any manual button binding.
Chapter 5: Emulator Cores #
RetroPie installs cores under /opt/retropie/ but ES-DE’s RetroArch integration expects
them in ~/.config/retroarch/cores/. The solution is symlinks. Applied for:
| System | Core |
|---|---|
| SNES | snes9x_libretro.so, snes9x2010_libretro.so |
| Genesis / Mega Drive | genesis_plus_gx_libretro.so, picodrive_libretro.so |
| PlayStation 1 | pcsx_rearmed_libretro.so |
| Game Boy / GBC | gambatte_libretro.so |
| Game Boy Advance | mgba_libretro.so, vba_next_libretro.so |
Recommended defaults: snes9x over snes9x2010 (the 2010 version is a frozen
low-power snapshot for original Raspberry Pi hardware — not needed on RK3588). For GBA,
mgba is the most accurate; vba_next is a fallback for edge cases.
Chapter 6: ROM Organisation #
With a large flat ROM directory, browsing in ES-DE becomes unwieldy. A bash script moves
files into thematic subfolders and updates gamelist.xml paths to preserve play history.
#!/bin/bash
ROM_DIR=~/RetroPie/roms/snes
GAMELIST=~/RetroPie/roms/snes/gamelist.xml
declare -A CATEGORIES=(
["Franchise A Games (USA)"]="TitleA1|TitleA2|TitleA3"
["Franchise B Games (USA)"]="TitleB1|TitleB2"
["Genre Games (USA)"]="Keyword1|Keyword2|Keyword3"
["Action Games (USA)"]="." # catch-all
)
for FOLDER in "${!CATEGORIES[@]}"; do
PATTERN="${CATEGORIES[$FOLDER]}"
mkdir -p "$ROM_DIR/$FOLDER"
find "$ROM_DIR" -maxdepth 1 -name "*.zip" \
| grep -iE "$PATTERN" \
| while read -r ROM; do
BASENAME=$(basename "$ROM")
mv "$ROM" "$ROM_DIR/$FOLDER/$BASENAME"
sed -i "s|<path>./$BASENAME</path>|<path>./$FOLDER/$BASENAME</path>|g" \
"$GAMELIST"
done
done
Check gamelist.xml for any <image> or <video> paths before running — if scraping
metadata has been done those need updating too.
Summary #
| Layer | Change | Effect |
|---|---|---|
| Frontend | ES-DE AppImage replacing RetroPie EmulationStation | Working frontend on Ubuntu 26.04 / GCC 15 |
| ES-DE GL context | MESA_GL_VERSION_OVERRIDE=3.3 + MESA_GLSL_VERSION_OVERRIDE=330 | Panfrost satisfies ES-DE’s GL 3.3 requirement |
| Wayland freeze | unset WAYLAND_DISPLAY in ES-DE launcher | RetroArch uses X11 context; no more silent freeze |
| ES-DE + VNC resolution | Wait for VNC connection, then xrandr --auto before launch | Correct resolution on every remote connect |
| Video driver | gl → vulkan | Lower CPU overhead per frame |
| Threaded video | off → on | Emulator + renderer on separate A76 cores |
| Swapchain depth | 3 → 2 | One fewer frame of display buffering |
| Frame delay | manual → auto | RetroArch self-tunes to vsync deadline |
| Hard sync | off → on | Eliminates CPU/GPU lag source |
| Audio latency | 64 ms → 32 ms | Halves audio delay |
| Run-ahead | off → on (1 frame) | ~16 ms input lag reduction |
| CPU affinity | none → taskset -c 4-7 | RetroArch stays on A76 big cores |
| Xbox controller | joypad_autoconfig_dir pointed to RetroPie profiles | Auto-detected on launch |
| Emulator cores | Symlinked from RetroPie to RetroArch cores dir | ES-DE can launch all systems |
| ROM organisation | Bash script → thematic subfolders + gamelist.xml update | Browsable library in ES-DE |
A Note on AI-Assisted Troubleshooting #
Every problem across this two-post series was diagnosed and fixed in one sitting with Claude (AI assistant running locally via Claude Code): the power supply root cause, the VNC reconnection race condition, the polkit popup, the entire audio routing setup, the GCC 15 compile failure, the GLX context mismatch, the Wayland freeze, the missing autoconfig path, and the ES-DE resolution timing issue.
Some of these look deceptively simple in hindsight. The hard reset pattern — no kernel panic, no log, only triggered by memory bandwidth pressure — is the kind of thing that looks like a kernel bug or hardware fault and sends you down long wrong paths for days. The audio routing went through several iterations: PipeWire TCP streaming, then a direct PulseAudio network sink, before landing on the raw PCM over SSH pipe approach — each with a concrete reason to move on. The Wayland freeze was a clean exit with no error message at all. The ES-DE resolution issue only appeared on VNC connections and only on the first launch, which is exactly the kind of timing bug that disappears when you go looking for it.
Having an assistant that can read logs, correlate stress test results, debug across the SBC and Windows side simultaneously, catch the waveout reinit failure that happened when pulseaudio.exe was restarted between sessions, and suggest the right unset WAYLAND_DISPLAY or port-polling loop on the spot changes the experience entirely. The problems didn’t get simpler — they got solved faster.