---
title: 'WarpEngine Batocera Store'
url: 'https://wiki.teletypegames.org/stores/warp-engine-batocera-store'
markdown: 'https://wiki.teletypegames.org/stores/warp-engine-batocera-store.md'
lang: en
date: '2026-08-15'
description: 'warp-engine-batocera-store pulls games from a WarpEngine catalog onto a Batocera box, with EmulationStation metadata and box art, and gives the store its own entry in the EmulationStation menu. This is the engine only: it knows the WarpEngine API and nothing about any particular site. The host, th…'
taxonomy:
  tag:
    - engine
---

# WarpEngine Batocera Store

> `warp-engine-batocera-store` pulls games from a [WarpEngine](https://wiki.teletypegames.org/engines/warp-engine) catalog onto a [Batocera](https://batocera.org) box, with EmulationStation metadata and box art, and gives the store its own entry in the EmulationStation menu. This is the engine only: it knows the WarpEngine API and nothing about any particular site. The host, the store name and where games land come from a `config.json` in a separate **store repository**. The half that is not about Batocera is [warpstore](https://wiki.teletypegames.org/engines/warpstore), shared with the [RetroArch engine](https://wiki.teletypegames.org/stores/warp-engine-retroarch-store).

```
warpstore                           the shared core — warpstore.py
        ▲
warp-engine-batocera-store          this engine — store.py, install.sh
        ▲
        │ config.json
        │
  ┌─────┴─────┬───────────────┐
  │           │               │
ttg-…       my-…            other-…       store repositories
  │           │               │
  ▼           ▼               ▼
teletypegames.org   my.example   other.example
```

The reference store is [ttg-batocera-store](https://wiki.teletypegames.org/stores/ttg-batocera-store).

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

# What you get

- **Its own menu entry** — one carousel entry named after the store, holding a folder per platform. Written as an `es_systems_<id>.cfg` overlay; the launch command and emulator list are copied off the box's own systems, so nothing about emulators is hardcoded.
- **Site-agnostic** — any host mounting WarpEngine can have a store; only the config changes.
- **Several stores per box** — each owns a ROM folder, a menu entry and its own state, so one store's prune cannot reach another's games.
- **The box is left alone** — the games live in the store's folder, not in the box's systems. Nothing outside it can be touched, and inside it only what `state.json` records is ever deleted.
- **Safe next to EmulationStation** — the in-ES sync defers the gamelist write until ES exits, and the tags ES owns (favourites, play counts) are carried over on every rewrite.
- **Zero dependencies** — Python 3 standard library only. Batocera ships `python3` and no `pip`.

# How it works

```
"<store name>" ▸ "Update <store name>"
        │
        ├─ GET /api/software                        the whole catalog
        ├─ keep platforms this box can run          c64 → c64, plus4 → cplus4, tic80 → tic80
        ├─ pick the newest non-dev release          that carries the right asset
        ├─ GET /api/download?path=<asset>           .prg / .tic into the store's folder
        ├─ GET <software.imageUrl>                  box art
        ├─ write gamelist.xml                       title, desc, author, image
        └─ write es_systems_<id>.cfg                the store's menu entry
```

---

# The store's own menu entry

Everything the store installs lives in one folder of its own, `/userdata/roms/<subfolder>`, with a directory per platform inside it. An `es_systems_<id>.cfg` in EmulationStation's user config declares one ES system per platform, all carrying the same `<group>` — and ES turns a group into a single carousel entry holding a folder per system:

```
main carousel:  …  ▸  Teletype Games  ▸  ┌ Commodore 64
                                         ├ TIC-80
                                         ├ Ports
                                         └ Update Teletype Games
```

Nothing about emulators is hardcoded. The engine reads the box's own `/usr/share/emulationstation/es_systems.cfg` and copies the launch command, the extensions, the platform, the theme and the emulator list off the system it borrows — a file Batocera generated for this image, so it lists exactly the emulators the box has. The one thing rewritten is `%SYSTEM%`: ES replaces it with the name of the system being launched, which for us would be `ttg-c64`, a name configgen has never heard of. The box's own system name goes in instead, so a game of ours is launched exactly as the box would launch it.

Five details, all of them EmulationStation's, and two of them cost an afternoon on a real box:

- **The menu entry is declared, not implied.** ES would invent the group parent itself, but its theme folder would then be the group's name, which no theme has — and `HideUniqueGroups` (on by default) dissolves a group with a single system in it unless a system by that name exists. The declared parent fixes both: it carries a `<theme>` (`ports` by default, present in every theme) and keeps the entry in place on a box that only has c64 games.
- **A system with no games of its own is dropped**, not merely hidden: `loadSystem` logs `System "..." has no games! Ignoring it.` and throws it away. So the entry cannot be an empty shelf — it *is* the `store/` folder, and the updater in it is the game that keeps it alive. That is also why *Update …* sits directly in the entry rather than in a subfolder.
- **The entry must not be the directory above the systems.** ES marks an enumerated directory with a `<dir>/*` entry in its file cache, and adds the mark *before* reading the contents; from then on any path under it that is not itself cached answers "does not exist" (`FileSystemUtil.cpp`, `getCacheEntry`). Systems load in a thread pool, so an entry sitting above the systems loses that race for a random couple of them, and ES drops those with `System "..." path does not exist !` — a different couple on every start, which looks exactly like a theme problem. `store/` is their sibling, so there is no race to lose.
- **An empty system does not show**, so the entry never has a dead end in it.
- **A new system needs an ES restart**, which the engine does after a sync that added one.

If the box already has a system named like the store id — a store called `nes`, say — the entry is called `<id>-store` instead and the log says so: an overlay whose `<name>` matches an existing system *modifies that system* rather than adding one.

## Its logo

EmulationStation resolves a system's carousel logo through the **theme** (`SystemData::getProperty("image")` asks the theme for `system/logo`, and the theme builds the path from the system's theme folder — carbon tries `art/logos/${system.theme}.png`, then `.svg`). There is no user-level override, so a custom system either borrows a theme folder that already has artwork, or the file goes where the themes look. Both are supported:

- without `menu.logo` the entry borrows `ports`, which every theme has, so it has *an* icon — just not the store's;
- with `menu.logo` (a path or URL, or a list of them for several formats) the engine installs it into each theme's logo folder. It finds that folder by looking for the logos of systems every theme has (`ports`, `snes`, `nes`, …) rather than by knowing the layouts, writes the file as `<menu.theme>.<ext>` — `menu.theme` then defaults to the store id, so nothing of the theme's own is overwritten — and records every path in `state.json`, so an uninstall takes exactly those back out and a name clash is reported and skipped;
- a theme it cannot reach shows the store's **name as text**, which is EmulationStation's own fallback when a logo image is missing.

Batocera wrinkle: themes that ship with the system image are under `/usr/share`, and Batocera's root is a RAM overlay — the copy shows up immediately but is gone after a reboot. So the engine (4.3.0+) keeps a marked block in `/boot/postshare.sh`, the one boot hook that runs before EmulationStation, which copies the cached logo back where it is missing; `/boot` is remounted read-write only for the write, the rest of the file is left alone, and `purge` takes the block out. Themes in `/userdata/themes` keep the logo anyway.

For the old behaviour — games inside the box's own systems, merged into its gamelists — set `emulationstation.menu.mode` to `merge`.

---

# Upgrading a store installed before the menu entry

Engine 4.0 is the layout change. The first sync after the upgrade takes the old install off the box — ROMs, box art, port payloads, our nodes in the box's gamelists, the empty folders, the Ports entry — and then downloads everything again into the store's own folder. Files are not carried across on purpose: the catalog is small, and a half-moved install is worse than a slightly longer sync. `-n` shows the whole thing without doing any of it.

---

# 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-batocera-store/raw/branch/master/install.sh |
  STORE_CONFIG=https://git.example.org/tools/my-store/raw/branch/master/config.json sh
```

From a checkout, with a local config:

```sh
scp -r warp-engine-batocera-store root@batocera:/tmp/
ssh root@batocera 'STORE_CONFIG=/tmp/my-config.json /tmp/warp-engine-batocera-store/install.sh'
```

It reads `store.id` from the config, installs the engine and the config into `/userdata/system/batocera-store/<id>/`, writes a `<id>-store` launcher, and runs the first sync — which is what creates the menu entry. EmulationStation has to restart to see it.

| Environment variable | Meaning |
|---|---|
| `STORE_CONFIG` | **required** — path or URL of the store's `config.json` |
| `BATOCERA_STORE_ROOT` | where stores live (default `/userdata/system/batocera-store`) |
| `BATOCERA_PORTS_DIR` | Ports folder (default `/userdata/roms/ports`) |
| `BATOCERA_PORT_NAME` | Ports entry name, for a store that still wants one (default: the store's `name`) |
| `ENGINE_RAW_BASE` | where to fetch `store.py` from |
| `WARPSTORE_RAW_BASE` | where to fetch `warpstore.py` from |
| `WARPSTORE_SRC` | a local `warpstore.py` to install instead of downloading it |

Every install also drops the logos cached in the store home, so a store that changes its logo gets the new one onto the box; with a local `STORE_CONFIG`, the logos named in `menu.logo` are taken from beside it.

---

# Writing a store repository

Three files:

```
my-batocera-store/
├── config.json     the store: URL, name, subfolder, platform mapping
├── install.sh      a wrapper that hands that config to the engine installer
└── README.md
```

`install.sh` in full:

```sh
#!/bin/sh
set -eu
FORGE=https://git.teletypegames.org
ENGINE_REF="${ENGINE_REF:-commit/<engine sha>}"
WARPSTORE_REF="${WARPSTORE_REF:-commit/<warpstore sha>}"
export ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-$FORGE/stores/warp-engine-batocera-store/raw/$ENGINE_REF}"
export WARPSTORE_RAW_BASE="${WARPSTORE_RAW_BASE:-$FORGE/engines/warpstore/raw/$WARPSTORE_REF}"
export STORE_CONFIG="${STORE_CONFIG:-https://git.example.org/tools/my-batocera-store/raw/branch/master/config.json}"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
curl -fsSL --retry 3 "$ENGINE_RAW_BASE/install.sh" -o "$TMP_DIR/install.sh"
sh "$TMP_DIR/install.sh"
```

Three things in it are deliberate. The engine and `warpstore` are **pinned** to commits (`raw/commit/<sha>`) the config was tested with, so a push to either `master` cannot change what a fresh install gets — after an engine release the store bumps both. The installer is **downloaded to a file** rather than piped into `sh`, so a failed download fails loudly. And that file sits **alone in a fresh directory**: the engine installer installs any `store.py` or `warpstore.py` it finds next to itself, and a bare `mktemp` file would make that `/tmp`. The [TTG store](https://wiki.teletypegames.org/stores/ttg-batocera-store) is a complete example, with a CI that has the pinned engine load its config.

Pick a `store.id` and a `paths.subfolder` nobody else uses — they are what keeps two stores on the same box out of each other's way, and the id is what names the menu entry and its `es_systems` file.

---

# Use

**On the device.** *"\\<store name>" ▸ "Update \\<store name>"*. The script downloads anything new, then restarts EmulationStation. A script launched by ES gets no console, so the output goes to `store.log` in the store home.

**Over SSH**, through the launcher the installer wrote:

```sh
S=/userdata/system/batocera-store/example-store

$S list                 # compatible catalog entries; * installed, ^ update available
$S sync                 # download everything new, refresh gamelists and the menu
$S sync blessingofra    # just one title (never prunes)
$S -n sync              # dry run
$S remove c64:demo      # uninstall (bare name works too)
$S purge                # uninstall everything this store installed
$S config               # effective configuration, and where the menu entry lives
```

Global flags go **before** the subcommand: `$S --roms-root /tmp/roms sync`.

---

# Uninstalling

```sh
ssh root@batocera 'curl -fsSL https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master/uninstall.sh | sh'
```

The engine does the removing, not the shell script: `purge` first, then any Ports entry, the launcher and the store home. Only `state.json` knows which ROMs, box art, port payloads, gamelist entries and ES systems were ours — and if it cannot finish (an unmounted ROMs root, say) 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; needed only when several are installed |
| `STORE_CONFIG` | alternative to `STORE_ID` — the id is read out of the config |
| `DRY_RUN` | `1` to print what would go and remove nothing |
| `KEEP_HOME` | `1` to keep the store home (state, catalog cache, log) for a reinstall |
| `BATOCERA_STORE_ROOT`, `BATOCERA_PORTS_DIR`, `BATOCERA_PORT_NAME` | as for the installer |

In the store's own layout the uninstall is total: the ROM folder, the gamelists and the `es_systems_<id>.cfg` all go, and the box is as it was. In `merge` mode these stay behind on purpose:

- **ROMs the user put in the store's subfolder** — a directory goes only when empty, so one stray file keeps it. `ports/.data` also stays: another store may keep a payload there.
- **the gamelists** — they belong to the box. Our `<game>` and `<folder>` nodes go; the user's own games, play counts and favourites are written back untouched. A gamelist we *created* and left empty is deleted, since it was never theirs.
- **`gamelist.xml.<store id>-backup`**, listed at the end of the run: the gamelists as we first found them.

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

```
/userdata/system/batocera-store/
├── example-store                       launcher for the store below
└── example/
    ├── store.py, warpstore.py          the engine and its shared core
    ├── config.json                     the store that selected it
    └── state.json, catalog.json, store.log

/userdata/system/configs/emulationstation/es_systems_example.cfg   the menu entry
<every theme>/<its logo folder>/example.png                         with menu.logo: the entry's logo
/boot/postshare.sh                      a marked block restoring the logo in /usr themes at boot

/userdata/roms/example/                 the store's own ROM folder
├── c64/
│   ├── blessingofra-2.2.0.prg, rabbit-1.0.0.prg, …
│   ├── images/blessingofra.png, …
│   └── gamelist.xml
├── ports/
│   ├── <name>.sh, images/<name>.png, .data/<name>/
│   └── gamelist.xml
└── store/                             the menu entry itself
    ├── update.sh                       the sync, as the menu starts it
    └── gamelist.xml
```

The box's own system folders are not touched at all. In `merge` mode — the old behaviour, still available — the same content goes to `<roms_root>/<system>/<subfolder>/`, the first `gamelist.xml` touched is copied to `gamelist.xml.<store id>-backup`, and every merge rewrites only the nodes under that store's subfolder, so two stores can share one gamelist.

---

# Configuration

`config.json` in the store home; the template is `config.example.json` in the repository.

| Key | Default | Meaning |
|---|---|---|
| `store.id` | `warp` | slug: names the store home, log prefix and gamelist backup |
| `store.name` | `WarpEngine Store` | display name of the menu entry |
| `store.base_url` | — | WarpEngine host |
| `store.api.catalog` | `/api/software` | catalog endpoint, if the engine is mounted elsewhere |
| `store.api.download` | `/api/download` | download endpoint |
| `paths.roms_root` | `/userdata/roms` | where systems live |
| `paths.subfolder` | `warp` | the store's own folder inside `roms_root` (in `merge` mode: its subfolder inside each system) |
| `emulationstation.menu.mode` | `system` | `system`: the store gets its own menu entry. `merge`: install into the box's own systems |
| `emulationstation.menu.name` | `null` → `store.name` | label of the menu entry |
| `emulationstation.menu.theme` | `null` → the store id with a logo, `ports` without | theme folder the entry takes its logo from, and the logo file's name |
| `emulationstation.menu.logo` | `null` | the entry's own logo: a path or URL, or a list of them; installed into every theme's logo folder |
| `emulationstation.menu.updater` | `true` | an *Update …* entry inside the menu that runs the sync |
| `emulationstation.menu.labels` | `{}` | per-system folder labels; a system not listed keeps the box's own `<fullname>` |
| `emulationstation.ports_entry` | `null` → `false` in `system` mode | also write a Ports entry that runs the sync |
| `emulationstation.folder_name` | `WarpEngine Store` | `merge` mode only: display name of our folder in the box's gamelist |
| `emulationstation.restart` | `true` | restart EmulationStation after a change |
| `emulationstation.config_dir` | `null` → `/userdata/system/configs/emulationstation` | where ES keeps its user config |
| `catalog.statuses` | `["released", "archived"]` | catalog `status` values to install |
| `catalog.owner_id` | `null` | restrict to one publisher (`/api/software?owner_id=`) |
| `catalog.only` / `catalog.exclude` | `[]` | software-name allow / deny lists |
| `platforms` | c64, plus4, tic80 | platform → system, asset kind, extension, enabled |
| `behavior.prune` | `true` | remove games that left the catalog or the filters |
| `behavior.timeout` | `30` | HTTP timeout, seconds |
| `behavior.insecure` | `false` | skip TLS verification (self-hosted test instances) |

## Platform mapping

A platform is installable when its release asset is a file a system this box has can boot directly — "has" meaning declared in the box's own `es_systems.cfg`, which is an emulator that got built into this image (a ROM folder can be there with nothing behind it):

| Catalog platform | Asset kind | Batocera system | Extension |
|---|---|---|---|
| [`c64`](https://wiki.teletypegames.org/platforms/c64) | `cartridge` | `c64` (VICE) | `.prg` |
| [`plus4`](https://wiki.teletypegames.org/platforms/plus4) | `cartridge` | `cplus4` (VICE `xplus4`) | `.prg` |
| [`tic80`](https://wiki.teletypegames.org/platforms/tic80) | `cartridge` | `tic80` | `.tic` |

The others (`ebitengine`, `love`, `godot`, `bevy`, `phaser`) ship `html` and per-OS zips, not a ROM a system launches, so they are not mapped by default. Adding one is a config edit:

```json
"platforms": { "godot": { "system": "godot", "kind": "linux_x64", "ext": ".zip", "enabled": true } }
```

---

# How a sync stays safe next to EmulationStation

EmulationStation keeps gamelists in memory and writes them back on exit, so a merge done while it runs can be clobbered. Launched from the menu (`sync-from-es`) the engine downloads first, then hands the merge to a detached `apply-gamelists --wait-pid <es-pid>` child that waits for the old ES process to die. From SSH, with no ES running, the merge is inline. The `es_systems` overlay needs none of that: ES reads it and never writes it.

A re-run is idempotent: existing files are left alone, a release bump (`1.1` → `2.0.0`) deletes the old asset before fetching the new one, and a gamelist or `es_systems` file whose content has not changed is not rewritten at all. Downloads go through `GET /api/download` rather than `/file/`, with a `warpstore/<version> (<store id>)` user agent; whether they count towards the catalog statistics is the host's call — teletypegames.org deliberately leaves `warpstore/*` out (`ignored_user_agents`).

A sync aborts when it cannot tell which systems the box has — an unreadable `es_systems.cfg` with an unmounted `/userdata/roms` behind it would otherwise look like "nothing is compatible any more" and prune every installed game.

# State

`state.json` records what the store installed, keyed `<system>:<name>`, so the same software name on two systems stays two entries. `layout` says which directory layout those records belong to: that is how the upgrade to the store's own folder knows to run once — it removes the old install, and the sync downloads it again into the new place. A `version: 1` file from the pre-split `ttg-store` is migrated on first run.

'; 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: [TTG RetroArch Store](https://wiki.teletypegames.org/stores/ttg-retroarch-store.md)
- Next: [WarpEngine Client](https://wiki.teletypegames.org/stores/warp-engine-client.md)
