warp-engine-retroarch-storepulls games from a WarpEngine catalog into RetroArch's library: one.lplplaylist per platform, with box art in the thumbnail folders the playlist's name points at. This is the engine only — the site, the store name and where games land come from aconfig.jsonin a separate store repository. The shared half is warpstore.
warpstore the shared core
▲
warp-engine-retroarch-store this engine — retroarch_store.py, install.sh
▲
│ config.json
ttg-retroarch-store store repositories
▼
teletypegames.org
The reference store is TTG RetroArch Store.
Repository: https://git.teletypegames.org/stores/warp-engine-retroarch-store Shared core: warpstore Sibling engine: Batocera store engine RetroArch: https://www.retroarch.com
retroarch.cfg.export), the only way to serve Android, which runs RetroArch but not Python.retroarch.cfg and never touches display preferences.<store>-retroarch-store sync
│
├─ GET /api/software the whole catalog
├─ keep platforms a libretro core boots c64 → VICE, tic80 → TIC-80
├─ pick the newest non-dev release that carries the cartridge
├─ GET /api/download?path=<asset> .prg / .tic into the content folder
├─ GET <software.imageUrl> box art, converted to PNG
└─ write <store> - <label>.lpl playlist + thumbnails
RetroArch is not one machine. Three design points follow.
It reads retroarch.cfg. The playlist folder is not always inside the RetroArch directory. On the macOS install this was developed against, playlist_directory is ~/Documents/RetroArch/playlists while cores and thumbnails live under ~/Library/Application Support/RetroArch. ~ is expanded, and so is the leading : a portable install uses for its own directory.
It can write for another machine. Android has no Python, so the only way to serve it is to render the tree elsewhere and copy it over. The same mechanism covers an SD card that will be mounted at a different path on the handheld.
It refuses to write while RetroArch is running. RetroArch keeps playlists in memory and writes them back on exit — favourites, last played, sort order — so a write underneath it can be undone. The Batocera engine defers instead, because the Ports menu is what launched it; here nothing launches us, so refusing is simpler and safer.
What is lost compared with Batocera: a .lpl entry has no description or developer field, so desc and author do not survive — RetroArch takes those from its own databases. And RetroArch cannot launch scripts, so there is no in-app trigger: the sync runs from a shell or a scheduler.
The installer takes the store as a parameter, so it works with any config:
curl -fsSL https://git.teletypegames.org/stores/warp-engine-retroarch-store/raw/branch/master/install.sh |
STORE_CONFIG=https://git.example.org/tools/my-store/raw/branch/master/config.json sh
It reads store.id from the config, installs the engine, the shared core and the config into ~/.local/share/warp-engine-store/example-retroarch/, writes an example-retroarch-store launcher into ~/.local/bin, prints the resolved paths and runs the first sync.
The store home is the store id with -retroarch appended, not the bare id: the desktop store engine shares this root, and a shared home would mean a shared config.json and state.json. An older install is moved on the next run of the installer.
| Environment variable | Meaning |
|---|---|
STORE_CONFIG |
required — path or URL of the store's config.json |
STORE_ROOT |
where stores live (default ${XDG_DATA_HOME:-~/.local/share}/warp-engine-store) |
BIN_DIR |
where the launcher goes (default ~/.local/bin) |
RETROARCH_DIR |
RetroArch's directory, if it is somewhere unusual |
STORE_SKIP_SYNC |
1 to install without syncing |
ENGINE_RAW_BASE |
where to fetch retroarch_store.py from |
WARPSTORE_RAW_BASE |
where to fetch warpstore.py from |
WARPSTORE_SRC |
a local warpstore.py to install instead of downloading |
S=~/.local/bin/example-retroarch-store
$S paths # resolved directories, and whether the cores are there
$S list # compatible entries; * installed, ^ update available
$S sync # download everything new, refresh the playlists
$S sync blessingofra # one title only (never prunes)
$S -n sync # dry run
$S remove c64:c64demo # uninstall (bare name works too)
$S purge # uninstall everything this store installed
$S config # effective configuration
Global flags go before the subcommand: $S --retroarch-dir /mnt/ra sync.
Restart RetroArch after a sync — it builds its menu at startup, so a new playlist does not appear in a running instance.
curl -fsSL https://git.teletypegames.org/stores/warp-engine-retroarch-store/raw/branch/master/uninstall.sh | sh
The engine does the removing, not the shell script: purge first, then the launcher and the store home. Only state.json knows which playlists, thumbnails and content files were ours — and if the engine cannot finish (RetroArch running, a directory gone) nothing else is touched, so you are never left with the files but without the engine that knows them.
| Environment variable | Meaning |
|---|---|
STORE_ID |
which store to remove; only needed with several installed |
STORE_CONFIG |
alternative to STORE_ID — the id is read from the config |
DRY_RUN |
1 prints what would go and removes nothing |
KEEP_HOME |
1 keeps the store home (state, catalog cache, log) for a reinstall |
FORCE |
1 purges even while RetroArch is running |
STORE_ROOT, BIN_DIR |
as for the installer |
What it leaves behind on purpose:
.lpl backups — each named after the playlist plus the store id and -backup, listed at the end of the run. They are the playlists as we first found them, so they may hold entries that were never ours.A dry run lists files but not directory removals: it cannot know which directories will end up empty.
To take every store off a machine — both engines, the shared core, the launchers — use the script in warpstore:
curl -fsSL https://git.teletypegames.org/engines/warpstore/raw/branch/master/uninstall.sh | sh
~/.local/share/warp-engine-store/example-retroarch/
├── retroarch_store.py, warpstore.py, config.json
└── state.json, catalog.json
<content_root>/example/c64/ blessingofra-2.0.0.prg, rabbit-1.0.0.prg, …
<playlists_dir>/Example Store - Commodore 64.lpl
<thumbnails_dir>/Example Store - Commodore 64/Named_Boxarts/Blessing of Ra.png
/Named_Titles/… /Named_Snaps/…
content_root defaults to a content folder inside the RetroArch directory; the other two come from retroarch.cfg. paths prints what was resolved and where each answer came from — run it first when a sync went somewhere unexpected.
Everything lands under the store's own paths.subfolder, which is all a prune may touch: never the user's content, never another store's. The playlist file carries the store's name, so it is ours — but an entry the user added survives a rewrite, because only items pointing inside the store's content folder are replaced. The first time a playlist is touched, a -backup copy is made.
{
"version": "1.5",
"default_core_path": "/…/cores/vice_x64_libretro.dylib",
"default_core_name": "VICE x64",
"label_display_mode": 0,
"right_thumbnail_mode": 0,
"left_thumbnail_mode": 0,
"thumbnail_match_mode": 0,
"sort_mode": 0,
"items": [
{
"path": "/…/content/example/c64/blessingofra-2.0.0.prg",
"entry_slot": -1,
"label": "Blessing of Ra",
"core_path": "DETECT",
"core_name": "DETECT",
"crc32": "4253CFC6|crc",
"db_name": "Example Store - Commodore 64.lpl"
}
]
}
default_core_path is a header field. That is the only way every entry starts with one click and no core prompt. Entries stay on DETECT, so the file still works where cores live elsewhere — worst case one prompt, not a dead entry.{store} - {label} never writes into RetroArch's own Commodore - 64.lpl and never collides with the official thumbnail packs. It is also the db_name, which is how RetroArch finds the thumbnails: the folder under thumbnails_dir named exactly like the playlist minus .lpl, with the Named_* folders inside.0 — whatever the user set globally. A store has no business overriding that.crc32 is real, computed from the downloaded file, so RetroArch can tie saves and thumbnails to the content.A missing core is a note, not an error. If the platform's core is not in libretro_directory, the playlist is still written: default_core_path stays empty, entries stay on DETECT, and RetroArch asks once. list and paths say so, with the fix:
c64: core not found: vice_x64_libretro.dylib — entries stay on DETECT
(Online Updater ▸ Core Downloader ▸ VICE x64)
Box art must be PNG. RetroArch looks for the label with .png and nothing else. The catalog serves whatever was uploaded — one of our covers is a GIF — and the standard library cannot re-encode an image, so the engine shells out to sips (built into macOS), magick, convert or ffmpeg, first one found. With none of them the entry has no box art, which beats a failed sync. The honest fix is upstream: WarpEngine could serve a PNG variant.
The cover is downloaded once and placed in all three Named_* folders, hardlinked where the filesystem allows.
exportsync installs into this machine's RetroArch. export renders the whole store into a canonical tree for somewhere else.
# Android over adb
$S export /tmp/ra-example \
--target-prefix /storage/emulated/0/RetroArch \
--target-libretro-dir /data/data/com.retroarch.aarch64/cores \
--core-suffix _android.so
adb push /tmp/ra-example/. /storage/emulated/0/RetroArch/
# An SD card mounted here, but at /mnt/sdcard on the handheld
$S export /Volumes/SDCARD/RetroArch --target-prefix /mnt/sdcard/RetroArch
The tree is always playlists, thumbnails and content inside the given directory. --target-prefix is what the playlists say the content is called on the machine that will run it; without it, the export directory's own path is used. Target paths are written the way the target spells them — POSIX unless the prefix looks like Windows.
export is stateless on purpose: it is a complete picture of the catalog, not an incremental update, and it must not disturb a local install in the same store home.
A catalog platform is installable when a libretro core boots its asset directly:
| Catalog platform | Asset kind | Core | Extension |
|---|---|---|---|
c64 |
cartridge |
vice_x64_libretro (VICE x64) |
.prg |
tic80 |
cartridge |
tic80_libretro (TIC-80) |
.tic |
Both cores are built for every relevant target — macOS x86_64/arm64, Linux x86_64/aarch64/armhf, Windows x86_64, Android arm64-v8a/armeabi-v7a — so the cartridges run everywhere RetroArch does.
The other WarpEngine platforms (ebitengine, love, godot, bevy, phaser) ship web builds and per-OS native archives. No libretro core runs those, so this engine cannot serve them; on a Batocera box they install as Ports instead.
The engine has been exercised end to end against the live catalog: download, playlists, thumbnails, GIF conversion, foreign-entry preservation, prune, uninstall, Android export, and a full install from the forge. Two things need a real run:
.prg, or load and wait for RUN? If it waits, the fix is a core option or shipping .d64.adb push wrote is untested.The design notes and the measurements this engine was built from are in devarea/RETROARCH_STORE.md.