Skip to main content
  1. Posts/

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:

SystemCore
SNESsnes9x_libretro.so, snes9x2010_libretro.so
Genesis / Mega Drivegenesis_plus_gx_libretro.so, picodrive_libretro.so
PlayStation 1pcsx_rearmed_libretro.so
Game Boy / GBCgambatte_libretro.so
Game Boy Advancemgba_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 #

LayerChangeEffect
FrontendES-DE AppImage replacing RetroPie EmulationStationWorking frontend on Ubuntu 26.04 / GCC 15
ES-DE GL contextMESA_GL_VERSION_OVERRIDE=3.3 + MESA_GLSL_VERSION_OVERRIDE=330Panfrost satisfies ES-DE’s GL 3.3 requirement
Wayland freezeunset WAYLAND_DISPLAY in ES-DE launcherRetroArch uses X11 context; no more silent freeze
ES-DE + VNC resolutionWait for VNC connection, then xrandr --auto before launchCorrect resolution on every remote connect
Video driverglvulkanLower CPU overhead per frame
Threaded videooff → onEmulator + renderer on separate A76 cores
Swapchain depth3 → 2One fewer frame of display buffering
Frame delaymanual → autoRetroArch self-tunes to vsync deadline
Hard syncoff → onEliminates CPU/GPU lag source
Audio latency64 ms → 32 msHalves audio delay
Run-aheadoff → on (1 frame)~16 ms input lag reduction
CPU affinitynone → taskset -c 4-7RetroArch stays on A76 big cores
Xbox controllerjoypad_autoconfig_dir pointed to RetroPie profilesAuto-detected on launch
Emulator coresSymlinked from RetroPie to RetroArch cores dirES-DE can launch all systems
ROM organisationBash script → thematic subfolders + gamelist.xml updateBrowsable 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.