It has to feel like a console
Installing RetroArch gets you a computer with an emulator on it. The console is everything between the power coming on and the first game starting.
For a long time I thought building a console was an emulator problem: install RetroArch, point it at a folder of ROMs, and you are done. What that actually produces is a computer with an emulator on it. The emulator was never the hard part — the hard part is everything between the power coming on and the first game loading, and if that part needs a keyboard, you have built a PC with one very specific job.
A console is a narrower promise: one screen, no keyboard, navigable with whatever is already in your hand, a game running within seconds of deciding to play. Almost every decision in this post exists to keep that promise.
The front door is not RetroArch
What runs on this box is Kodi. Not as a desktop application — it is kodi-gbm, started by systemd on tty1, and it takes
the display’s DRM device directly instead of rendering as a window inside a compositor. That sounds like an implementation
detail and it is the whole design: RetroArch, the thing I actually turned up to build, is one entry on Kodi’s screen. The
console is Kodi; the emulator is an application inside it.
It is also why I never sit through a boot sequence. The Pi is never switched off — the TV goes off and the Pi stays on, so Kodi is already there when I pick the remote back up. I would like to say that was deliberate, but the truth is that the box has no clean way to cut its power yet. It happens to be exactly how I want a console to behave: waiting for something to boot is a computer behaviour.
Open the deep-dive — why Kodi has to own the screen itself ›
Putting Kodi inside a Wayland compositor is the obvious arrangement and it does not render on this hardware. nixpkgs
builds kodi-wayland against desktop OpenGL, and the Pi’s v3d driver is OpenGL ES only — the combination gets far
enough to pass Kodi’s own shader checks and then draws nothing: a black screen with an active render loop. It is also
worth knowing that this is the reason LibreELEC, the long-lived Kodi-on-a-Pi distribution, runs GBM rather than Wayland.
The consequence for everything else in this post is that Kodi is not a well-behaved client that can share the display. It is the thing holding the display, which is what makes handing it over to a game a problem worth a section.
The TV remote just works
Kodi speaks HDMI-CEC out of the box. The TV forwards the remote’s button presses down the HDMI cable, Kodi acts on them, and that is the entire setup: no receiver, no pairing, no configuration — the same remote that changes the channel navigates the library. It gives you four arrows, OK and back, which turns out to be the complete input vocabulary a launcher needs.
What it does not give you is a keyboard, so anything textual is still a job for one. The Pi 400 happens to be a keyboard with the whole computer inside it, so there is nothing to plug in and nothing to go looking for — the one place where that machine’s odd form factor is a real advantage.
What it looks like
Sitting down, what is on the TV is Kodi’s home screen with a tile for RetroArch on it. That tile is the whole of this part: an icon, a name, and a command.
Selecting it drops into RetroArch’s own menu, which is the stock ozone interface with the “Purple Rain” colour theme — that is one number in my configuration, not a project. The library behind it is not a file browser either: it is a playlist per console, with the box art next to each entry, so what I navigate on the sofa is a shelf rather than a directory tree. RetroAchievements are switched on, and controllers are pinned to player ports by name, so the 8BitDo is player one and the Xbox pad is player two by identity rather than by whichever one was plugged in first. Where all of those playlists come from is the next part.
retroarchHandoff = lib.optional retroCfg.enable {
slug = "retroarch";
fullname = "RetroArch";
# RetroArch ships its logo as SVG only, which Kodi cannot render.
icon = self.lib.svgToPng pkgs {
name = "retroarch-icon";
src = "${pkgsStock.retroarch}/share/icons/hicolor/scalable/apps/com.libretro.RetroArch.svg";
};
command = "${lib.getExe pkgsStock.cage} -- ${
pkgs.writeShellScript "kodi-retroarch-kiosk" ''
${forceMode}
exec ${retroCfg.kioskScript}
''
}";
};
appHandoffs = map (app: {
slug = slugify app.name;
fullname = app.name;
inherit (app) command icon;
}) cfg.apps;
allHandoffs = retroarchHandoff ++ appHandoffs;
# An empty thumb is what makes Kodi fall back to the generic star, so
# only emit the attribute when there is actually an image. Kodi's image
# loader does not render SVG, so these must be raster files.Starting a game has to kill the launcher
For a game to run, Kodi has to stop. That is not a stylistic choice — a Favourite that launches RetroArch as a child process looks like it works and then dies, and giving the game its own session does not fix it either. Both failures are written up below, because they explain the shape of the whole design.
So each entry on the home screen is a systemd unit that conflicts with Kodi’s own. Selecting it starts the unit, systemd stops Kodi to resolve the conflict, cage runs RetroArch with the display mode forced, and when you quit, the unit’s stop action starts Kodi again. A pleasant side effect of doing it this way: no virtual terminal switching happens at all.
Open the deep-dive — the handoff unit, and two ways a child process fails ›
The first failure: a game launched as a child of Kodi inherits Kodi’s logind session, which is bound to VT1. Switching away from that virtual terminal marks the session inactive, logind revokes its device access, and the emulator dies instantly.
The second is the interesting one. Handing the game a session of its own is not enough, because Kodi opened
/dev/dri/card0 directly rather than through logind. There is no session for logind to take the GPU away from, so cage
fails with Could not take device: Device or resource busy even when it is running in a correct, active session. Kodi
has to genuinely stop for the GPU to be free.
Kodi also runs as an unprivileged user, so starting one of these units needs authorisation: a polkit rule scoped to exactly these units by name, rather than handing the user blanket rights over systemd.
One line in there deserves calling out, because nothing about it looks important: systemctl start --no-block. A blocking
start waits for the job to finish, and starting Kodi requires stopping the very unit that is running the action waiting on
it — so the stop action blocks on a stop of itself, which cannot finish, until systemd’s stop-post timeout expires and
fails the entire rebuild. Queueing the start instead of waiting for it is the difference between a deploy that works and a
box that needs hands on the keyboard.
# A Favourite starts a systemd unit; it must NOT run the target as a
# child of Kodi. Both of these were verified on the box:
#
# * A `System.Exec` child inherits Kodi's logind session, which is
# bound to VT1 (`loginctl`: it is the only seat0 session, VTNr=1).
# Switching VT away marks that session inactive, logind revokes its
# device access, and the child dies instantly. A unit with PAMName +
# TTYPath gets a session of its own instead.
# * Kodi opens /dev/dri/card0 directly rather than through logind, so
# logind cannot take DRM master away from it on a VT switch. cage
# then fails with "Could not take device: Device or resource busy"
# even from a correct, *active* session on another VT. So Kodi has to
# genuinely stop for the GPU to be free -- hence `conflicts`, with
# ExecStopPost bringing it back when the target exits. A pleasant
# consequence: no VT switching is involved at all.
handoffUnit = h: "kodi-handoff-${h.slug}";
mkHandoffService = h: {
conflicts = [ "kodi-tty1.service" ];
restartIfChanged = false;
unitConfig.ConditionPathExists = "/dev/tty1";
serviceConfig = {
ExecStart = h.command;
# `+` runs this with full privileges regardless of User=, so
# bringing Kodi back needs no polkit rule of its own.
#
# --no-block is load-bearing, not a nicety: a plain `systemctl
# start` waits for the job to finish, and starting kodi-tty1
# requires stopping *this* unit (they conflict) -- which cannot
# finish while its own ExecStopPost is still running. That deadlocks
# until systemd's stop-post timeout fires and fails the unit, which
# in turn fails the whole nixos-rebuild switch. Queue it instead.
ExecStopPost = "+${config.systemd.package}/bin/systemctl start --no-block kodi-tty1.service";
User = cfg.user;
PAMName = "kodi";
TTYPath = "/dev/tty1";
TTYReset = "yes";The display is a trap
Wayland takes the display’s preferred mode, and a TV’s preferred mode is 4K. A Pi 4 cannot clock 4K at 60Hz without
hdmi_enable_4kp60, so it quietly settles on 4K at 30 — and 30 does not divide either 60Hz or 50Hz content evenly, while
the GPU spends its entire budget compositing eight million pixels to draw a game that is 240 lines tall. So the mode is
pinned to 1920×1080@60, and everything downstream inherits a sane mode by default.
A controller that does nothing
Plugging a controller into this box accomplishes nothing until you also add the joystick support to Kodi — the package the Pi builds ships no binary add-ons at all, so as far as the interface is concerned the controller does not exist. Pairing one over Bluetooth is the same story: it has to happen inside Kodi, from the couch, rather than over SSH.
Open the deep-dive — why pairing a controller cannot be a shell command ›
A text user interface cannot receive joystick events, so there is no way to pair a controller from a shell. The pairing flow has to live inside Kodi as an add-on that owns the SSP prompts (confirmation, passkey, PIN display), which is why this box carries a third-party Bluetooth manager built from a specific commit: it is untagged upstream, and the released tags do not have those handlers.
The same pattern explains the Wi-Fi entry on the home screen: a tile that shells out to a text user interface, because that is what was available, rather than a Kodi-native network settings page.
# the remaining suspect for N64 crackling.
n64Core = config.libretro.mkLeanCore {
core = gles3Core;
parallelRdp = false;
parallelRsp = false;
};
retroarchGles3 = config.libretro.mkGles3Frontend {
inherit pkgs;
cores = [
n64Core
pkgs.libretro.snes9x
pkgs.libretro.nestopia
pkgs.libretro.genesis-plus-gx
pkgs.libretro.mgba
pkgs.libretro.pcsx-rearmed
pkgs.libretro.beetle-psx-hw
];
};
in
{
modules = {
profiles.rpi.enable = true;
gaming.retroarch.enable = true;
gaming.retroarch.package = retroarchGles3;
# Metrics for the central Prometheus (modules/monitoring/node-exporter.nix).What is still not a console
- Joining a Wi-Fi network is still a text interface on tty1, so it needs the keyboard.
- A deploy restarts Kodi, which is a few seconds of black screen. It is a deliberate act, so it is acceptable, but it is not something a console would ever do.
- Nothing in daily use, which is its own result. The one time this box locked me out was a laptop swap: the new public key never made it into the config, so SSH stopped answering. The way back in was to pull the SD card out, mount it on the laptop and add the key to the card by hand. The failure mode of a machine you administer over the network is physical access to its disk.
- PSX and N64 are the ceiling. Anything older runs better than the original hardware; those two run well enough to play and not well enough that I forget the emulator is there.
Most of this post is unglamorous configuration: a conflict directive, a display mode, an icon conversion. It is also the difference between a machine I use and one I would have abandoned after a week — and everything that follows assumes you actually sit down in front of it. What it reads from is the next part.