---
title: 'WarpEngine RetroArch Store'
url: 'https://wiki.teletypegames.org/stores/warp-engine-retroarch-store'
markdown: 'https://wiki.teletypegames.org/stores/warp-engine-retroarch-store.md'
lang: en
date: '2026-08-17'
description: "warp-engine-retroarch-store pulls games from a WarpEngine catalog into RetroArch's library: one .lpl playlist 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 a config.json in a sepa…"
taxonomy:
  tag:
    - engine
---

# WarpEngine RetroArch Store

> `warp-engine-retroarch-store` pulls games from a [WarpEngine](https://wiki.teletypegames.org/engines/warp-engine) catalog into RetroArch's library: one `.lpl` playlist 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 a `config.json` in a separate **store repository**. The shared half is [warpstore](https://wiki.teletypegames.org/engines/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](https://wiki.teletypegames.org/stores/ttg-retroarch-store).

**Repository:** <https://git.teletypegames.org/stores/warp-engine-retroarch-store>**Shared core:** [warpstore](https://wiki.teletypegames.org/engines/warpstore)**Sibling engine:** [Batocera store engine](https://wiki.teletypegames.org/stores/warp-engine-batocera-store)**RetroArch:** <https://www.retroarch.com>

# What you get

- **Every machine RetroArch runs on** — desktop Linux, Windows, macOS, Android, Steam Deck, the console ports. The same cartridges the Batocera store installs, a much larger audience.
- **No assumed paths** — the four directories it needs come from the user's own `retroarch.cfg`.
- **It can write for a machine it is not running on** (`export`), the only way to serve Android, which runs RetroArch but not Python.
- **It never edits `retroarch.cfg`** and never touches display preferences.
- **Zero dependencies** — Python 3 standard library only.

```
<store>-retroarch-store sync
        │
        ├─ GET /api/software                      the whole catalog
        ├─ keep platforms a libretro core boots   c64 → VICE x64, plus4 → VICE xplus4, 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
```

---

# Why this is not the Batocera store

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.

---

# Installing a store

The installer takes the store as a parameter, so it works with any config:

```sh
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 |

Run from a file, the installer prefers a `retroarch_store.py` or `warpstore.py` lying next to itself over the downloaded one — that is how a checkout installs its own code. A wrapper that fetches `install.sh` must therefore save it into a directory of its own (`mktemp -d`), never a bare file in `/tmp`, or whatever stray copies sit there get installed.

---

# Use

```sh
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.

---

# Uninstalling

```sh
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:

- **content the user put in the store's folder** — a directory goes only when empty, so one stray file keeps it.
- **a playlist that still has their entries** — only items pointing inside the store's content folder are dropped. A playlist left empty is deleted; one that is not is rewritten without us.
- **the `.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](https://wiki.teletypegames.org/engines/warpstore):

```sh
curl -fsSL https://git.teletypegames.org/engines/warpstore/raw/branch/master/uninstall.sh | sh
```

---

# Where things land

```
~/.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.

---

# The playlist

```json
{
  "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"
    }
  ]
}
```

- **One playlist per platform**, because `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.
- **The name is the namespace.** `{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.
- **The display fields are all `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.

---

# Another machine: `export`

`sync` installs into this machine's RetroArch. `export` renders the whole store into a canonical tree for somewhere else.

```sh
# 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.

---

# Platform mapping

A catalog platform is installable when a libretro core boots its asset directly:

| Catalog platform | Asset kind | Core | Extension |
|---|---|---|---|
| [`c64`](https://wiki.teletypegames.org/platforms/c64) | `cartridge` | `vice_x64_libretro` (VICE x64) | `.prg` |
| [`plus4`](https://wiki.teletypegames.org/platforms/plus4) | `cartridge` | `vice_xplus4_libretro` (VICE xplus4) | `.prg` |
| [`tic80`](https://wiki.teletypegames.org/platforms/tic80) | `cartridge` | `tic80_libretro` (TIC-80) | `.tic` |

All three 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](https://wiki.teletypegames.org/stores/warp-engine-batocera-store) instead.

---

# Not yet verified on real hardware

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:

- **Does the VICE core auto-start a raw `.prg`**, or load and wait for `RUN`? If it waits, the fix is a core option or shipping `.d64`.
- **The Android push** — the paths above are documented defaults, not measurements, and whether scoped storage lets RetroArch read what `adb push` wrote is untested.

The design notes and the measurements this engine was built from are in `devarea/RETROARCH_STORE.md`.

'; var prevLevel = 2; for (var i = 0; i < headings.length; i++) { var h = headings\[i\]; var text = h.textContent.trim(); var slug = text.toLowerCase() .replace(/\[áàâä\]/g,'a').replace(/\[éèêë\]/g,'e').replace(/\[íìîï\]/g,'i') .replace(/\[óòôöő\]/g,'o').replace(/\[úùûüű\]/g,'u') .replace(/\[^a-z0-9\]+/g, '-').replace(/^-|-$/g, ''); if (!slug) slug = 'heading-' + i; if (used\[slug\]) { slug += '-' + i; } used\[slug\] = true; h.id = slug; var level = parseInt(h.tagName.charAt(1)); var cls = level > 2 ? ' class="toc-child"' : ''; html += '- [' + text + '](#'%20+%20slug%20+%20')
'; } html += '
'; toc.innerHTML = html; // Scroll-spy: highlight current heading var links = toc.querySelectorAll('a'); var ids = \[\]; for (var j = 0; j < links.length; j++) ids.push(links\[j\].getAttribute('href').slice(1)); function updateActive() { var current = ''; for (var k = 0; k < ids.length; k++) { var el = document.getElementById(ids\[k\]); if (el && el.getBoundingClientRect().top <= 80) current = ids\[k\]; } for (var m = 0; m < links.length; m++) { links\[m\].parentNode.classList.toggle('active', links\[m\].getAttribute('href') === '#' + current); } } window.addEventListener('scroll', updateActive, { passive: true }); updateActive(); })();

---

## Navigation

- Parent: [Stores](https://wiki.teletypegames.org/stores.md)
- Previous: [WarpEngine Client](https://wiki.teletypegames.org/stores/warp-engine-client.md)
- Next: [WARP64U](https://wiki.teletypegames.org/stores/warp64u.md)
