2026·04·19 10 min read #yocto #embedded-linux #rockchip #rk3566 #turborelic #bsp #wayland #retroarch

Building TurboRelic: a Yocto distro for the Anbernic RG353V, from first boot to a hot pink web dashboard

Seven weeks, 62 libretro cores, a custom web dashboard streaming the screen in HW-encoded MJPEG, and the BSP decisions I'd make again.


I picked up an Anbernic RG353V - a €100 Rockchip RK3566 handheld - somewhere around the end of February 2026. By mid-April it was running a custom Yocto distro I built from scratch, with 62 libretro cores, Moonlight game streaming, a Wayland compositor, a hot-pink web dashboard that streams the screen back at 60fps over hardware-encoded MJPEG, and six pygame diagnostic ports written for the device itself.

Seven weeks, ~45 ADRs (architecture decision records), and roughly 7,700 bitbake tasks per gaming-image build. This is the writeup of what went into it, what I’d do again, and what I’d rip out.

The project is called TurboRelic. It is, to my knowledge, the only from-scratch Yocto distribution for this device.

The brief

The device already had two usable options: Batocera (buildroot-based, huge ecosystem), and the vendor’s Android + RetroArch image. Neither was what I wanted.

The brief I gave myself:

  1. Yocto-based, reproducible, driven by kas-container so anyone can rebuild the whole distro with a single command.
  2. Retro emulation on par with Batocera - same frontend (EmulationStation), same configgen-driven RetroArch, same set of cores.
  3. Moonlight game streaming from my Ghibli desktop over WiFi.
  4. A web dashboard on the device to control it from a phone. (This one was mission-creep. It became my favorite part.)
  5. A/B rootfs with OTA updates via SWU, because I was going to brick this thing a lot.

The stack

plaintext
TurboRelic/
├── kas/
│   ├── base.yml
│   └── gaming.yml
├── meta-rg353v/        # ours - BSP + integration
├── meta-retro/         # ours - 62 libretro cores
├── meta-swupdate/      # swupdate A/B OTA framework
├── meta-openembedded/  # standard
└── poky/               # scarthgap

Five layers. meta-rg353v is where the hardware lives - machine config, device tree fragments, kernel cfg, U-Boot patches, ALSA state, gamepad-merge daemon, tool ports, web dashboard. meta-retro is an out-of-tree layer I maintain for RetroArch + 62 libretro cores.

Build:

bash
kas-container build kas/base.yml:kas/gaming.yml

That gives you rg353v-image-gaming.wic.img (~2.6 GiB sparse) and rg353v-image-gaming.swu for OTA updates. One command, reproducible, no host contamination.

The hardware, one paragraph

RK3566 (quad-core Cortex-A55, Mali G52 GPU). 2 GB LPDDR4. 32 GB eMMC. 3.5" 640×480 DSI panel (ST7703 V2). WiFi/BT combo on SDIO (RTL8821CS). rk817 audio codec (SoC-internal). Analog sticks via SARADC + GPIO-muxed channels. Face buttons via gpio-keys. Dual SD slot. USB 3.0 OTG. Hantro VPU for hardware video encode/decode. It’s a small Rockchip reference board in a plastic shell, and that’s most of what you need to know.

Five stage gates, seven weeks

I ran the project as five sequential stage gates. No work on stage N+1 until stage N was validated on hardware. It’s a discipline I use on client projects and it’s saved me from a LOT of stacked debugging.

StageWhatDone on
1Kernel boots, framebuffer console visible2026-02-28
2All peripherals recognized (WiFi, audio, gamepad, GPU)2026-03-01
3rg353v-image-base boots cleanly to shell2026-03-03
4rg353v-image-gaming launches ES + RetroArch2026-03-16
5Moonlight streams from ES2026-03-17

Stage 4 was the long one. Stages 1-3 were three days because the Rockchip mainline support for RK3566 is actually pretty good now - the real work was in the BSP integration (display power, SDIO WiFi, gamepad, audio routing). Stage 6 (“polish + optimization”) is the ongoing phase and is where most of the interesting work lives.

The things that almost broke me

Display: mainline ATF doesn’t power the VO domain

First boot: U-Boot loads, kernel boots, no display. Second boot: display works. Cold boot: no display.

Spent a week on this. Batocera doesn’t hit it because Batocera’s U-Boot carries a specific patch that enables the VO (video output) power domain before jumping to kernel. Mainline ATF + mainline U-Boot will boot the kernel in a state where the display is not powered, and the kernel silently assumes somebody upstream of it did the right thing.

Fix was three lines in the U-Boot tree plus:

I also tried the “modern” route - KMS-only, no fbcon, let the compositor own everything. Wasted another week. At 640×480 the extra layer doesn’t buy you anything, and the splash-screen bootchain is dramatically simpler with fbcon in place. My ADR-031 reverted that decision and pinned us to fbcon + fbdev client.

WiFi: the RTL8821CS combo chip

SDIO WiFi would fail to associate on about one boot in three. Error was -110 (ETIMEDOUT) on the SDIO probe.

Turns out the RTL8821CS is a combo WiFi+BT chip, and without CONFIG_BT_HCIUART_RTL=y in the kernel config, the BT firmware doesn’t load in the right sequence, which somehow destabilizes the SDIO side. Enable the Bluetooth UART driver even if you don’t plan to use Bluetooth. WiFi becomes 100% reliable.

I don’t fully understand this one. I have read the driver sources. I still don’t fully understand it. It just is.

Audio: the rk817 SPK/HP mux defaults to silence

rk817 has two output paths - speakers and headphones. The Playback Mux control defaults to HP=0 on cold boot. If no headphones are plugged in, you get silence, and the jack-detect event never fires because nothing was inserted.

Fix is an ALSA state recipe that ships an asound.state setting the mux to SPK, plus a udev rule + small shell script that toggles on jack events:

shell
# 90-alsa-jack.rules
SUBSYSTEM=="input", ATTRS{name}=="rk817_int Headphones", \
  ENV{ACTION}=="change", RUN+="/usr/bin/alsa-jack.sh"

Volume also defaults to near zero. We ship at 201/255 (~-20 dB). Loud enough to hear, not loud enough to kill the cheap speaker.

Gamepad: merging three input devices into one

The RG353V has analog sticks on SARADC, face buttons on gpio-keys, and the D-pad on its own adc-keys channel. RetroArch sees three separate input devices and gets confused about which slot owns what.

Solution was a small userspace daemon - gamepad-merge - that listens on all three /dev/input/event* sources, merges them, and emits a single synthesized uinput device called RG353V Gamepad. RetroArch and EmulationStation only see the merged one. Ships as a systemd service.

It’s 120 lines of C. It’s the single most under-appreciated piece of the whole distro.

Stage 6: where it got fun

Once the core stack worked, I spent the rest of the project on polish and features. The list runs long, so here’s what mattered most:

TurboRelic Deck - the hot pink web dashboard

TurboRelic Deck - streaming the EmulationStation menu with virtual gamepad

Runs on port 80 on the device. Connect from your phone on the same WiFi, bookmark it, point your browser at http://rg353v.local/.

TurboRelic Deck - streaming Super Mario World live from the device

What it does:

Theme is hot pink (#ff69b4) because the previous theme was too quiet. Also ASCII-art controls, iOS-responsive layout, slim pill tabs. It looks like someone’s homework from 2008 and I’m not sorry.

62 libretro cores and a custom ES theme

meta-retro contains 62 libretro cores, covering every system anyone reasonably plays on a handheld plus some they don’t (VIC-20, Plus/4, Sharp X68000 - you know who you are).

ROM scraping works via ScreenScraper + TheGamesDB. RetroAchievements works on the systems that have it (PS1, GBC, NES, SNES, Genesis, a few others - 101 cores tested, 5 had broken RA integration that got removed).

ES theme is a custom one (es-theme-turborelic) - pink on dark, 65 system logos (art-book-next SVGs tinted pink and exported at 80px), animated scanlines, smaller metadata overlays, box-art nudged by 10px because it bothered me.

Six pygame diagnostic tools

Under “Ports” in ES there are six tools I wrote for the device itself. Pink-on-dark theme, DogicaPixel font:

  1. System Info - live CPU/RAM/temp/battery/storage readout.
  2. Input Tester - visual gamepad diagram with button highlights, stick position, event log. Hold START+SELECT to exit.
  3. Performance Monitor - scrolling time graphs for 4 cores, memory, CPU/GPU temps, battery.
  4. Display Test - color bars, grayscale ramp, alignment grid, checkerboard, dead-pixel test. A/D-pad cycles patterns.
  5. Audio Mixer - volume slider, mute toggle, test tones (220/440/880 Hz), L1/R1 frequency select, VU meter.
  6. WiFi Manager - scan, on-screen keyboard with 3 pages, WPA connect via wpa_cli. Supports multiple saved networks. Doesn’t fight batocera.conf’s single-WiFi model.

The dirty trick: these all depend on pygame’s bundled SDL2, which does not have a Wayland backend, which means they’d fall back to X11 and fail. port-launcher is a 10-line wrapper that sets LD_PRELOAD=/usr/lib/libSDL2-2.0.so.0 to force the system SDL2 (with Wayland) over pygame’s bundled one. Solves it for every pygame app we ship.

Installer + OTA: batocera-install and SWU

Two update paths:

First-time install - a Yocto-built rg353v-image-gaming.wic.img gets flashed to an SD card with dd. The device boots from SD. From there, run batocera-install to clone the SD to eMMC with sparse dd (GNU dd 9.4 with conv=sparse + status=progress, turning a 28 GiB SD image into ~2.6 GiB of actual writes). Partitions are created at 3000 MB each and expanded to fill eMMC on first boot.

Updates - rg353v-image-gaming.swu is uploaded via the web UI on port 8080 (swupdate’s own nginx-based UI). A/B rootfs swap. Failed update stays in the B slot; device boots back to A on next reboot. We’ve shipped ~40 OTA updates so far and the A/B mechanism has saved me from at least two bricking-level updates.

The version history in one table

VersionCodenameWhat landed
1.0.0MistralFirst official release - five stages validated
1.0.1 - 1.0.3MistralScreenScraper default + ES theme overhaul
1.2.0ChinookDeep sleep + Moonlight remote play
1.3.0 - 1.3.7Zephyr → TramontaneRGSX handheld, six tool ports, hold-to-repeat, brightness slider
1.4.0 - 1.4.1Maestrale101 cores updated, 5 RA-broken removed, PSX analog fixes

Every release codename is a Mediterranean wind. I don’t remember deciding this but here we are.

What I’d do again

What I’d rip out

The source, if you want it

GitHub: bomba5/turborelic-public. MIT licensed. Issues welcome. PRs doubly welcome.

The web dashboard is under meta-rg353v/recipes-gaming/turborelic-deck/. The gamepad-merge daemon lives at meta-rg353v/recipes-gaming/gamepad-merge/. The installer is a shell script at meta-rg353v/recipes-core/batocera-scripts/files/batocera-install.

If you own one of these devices and want to try it: flash the .wic.img to an SD card, boot, run batocera-install to clone to eMMC, optionally run the WiFi Manager port to get online, then upload ROMs via the web dashboard or SFTP.

Pull requests and issues very much welcome.