---
title: warpstore
url: 'https://wiki.teletypegames.org/engines/warpstore'
markdown: 'https://wiki.teletypegames.org/engines/warpstore.md'
lang: en
date: '2026-08-17'
description: "A store engine reads a WarpEngine catalog and writes some host's own game library format. Only that last step differs per host. Talking to the API, picking a release, matching the machine and remembering what went where is the same work everywhere — warpstore is that work, in one file, shared by e…"
taxonomy:
  tag:
    - engine
---

# warpstore

> A **store engine** reads a [WarpEngine](https://wiki.teletypegames.org/engines/warp-engine) catalog and writes some host's own game library format. Only that last step differs per host. Talking to the API, picking a release, matching the machine and remembering what went where is the same work everywhere — `warpstore` is that work, in one file, shared by every store engine.

```
                    ┌──────────────────────────────┐
                    │  warpstore.py                │
                    │  catalog · releases · host   │
                    │  state · config · HTTP       │
                    └──────────────┬───────────────┘
                    ┌──────────────┴───────────────┐
                    ▼                              ▼
    warp-engine-batocera-store        warp-engine-retroarch-store
    ROM folders + gamelist.xml        .lpl playlists + thumbnails
                    │                              │
                    ▼                              ▼
             EmulationStation                  RetroArch
```

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

# Why it exists

The Batocera store was 1073 lines, and only about a third was about Batocera. The RetroArch store needed the other two thirds, so the shared part moved out — with a second consumer already in hand, so the seam sits where the two engines really differ.

Single file, Python 3 standard library only: the hosts range from a Batocera box (`python3`, no `pip`) to a desktop, and neither may be asked to install anything. An engine's installer drops `warpstore.py` next to the adapter script, and the adapter does `import warpstore`.

# What an adapter supplies

1. **`DEFAULT_CONFIG`** — where things live on this host, which catalog platform maps to what.
2. **`accept(spec, sw)`** — which catalog entries this host can run, and how they are grouped.
3. **The code that writes the host's library format.**

```python
import warpstore as ws

def accept(spec, sw):
    """Return (scope, extras), or raise ws.Skip(reason) to reject the title."""
    if spec["system"] not in available_systems:
        raise ws.Skip(f"no '{spec['system']}' ROM folder on this box")
    return spec["system"], {"system": spec["system"]}

cfg = ws.load_config(path, DEFAULT_CONFIG, required=("paths.subfolder",))
ws.init(path)
games, skipped = ws.select_games(cfg, ws.fetch_catalog(cfg, use_cache=True), accept)
```

`scope` is how the host groups its library — a Batocera system, a RetroArch playlist. `state.json` is keyed by `<scope>:<name>`, so the same software name in two scopes stays two entries.

# What is in it

| Area | Functions |
|---|---|
| Logging | `set_tag`, `set_verbose`, `log`, `debug`, `die` |
| Files | `load_json`, `write_json`, `write_atomic`, `within`, `prune_empty_dirs` |
| Config | `deep_merge`, `load_config` |
| Store home | `init`, then `HOME`, `CONFIG_PATH`, `STATE_PATH`, `CATALOG_CACHE` |
| HTTP | `http_get`, `http_download`, `api_url`, `download_url`, `user_agent` |
| Host | `machine`, `host_os`, `host`, `resolve_for_host` |
| Catalog | `fetch_catalog`, `select_games`, `survey_catalog`, `pick_release`, `asset_basename`, `download_image` |
| State | `load_state`, `save_state`, `game_key`, `record_scope`, `records_by_scope`, `match_keys`, `limit_to_names` |

Five of them carry decisions worth knowing.

**`select_games` vs. `survey_catalog`** — `select_games` returns what the store can install. `survey_catalog` returns that plus one record per title this machine cannot install, with a `reason` code (`platform_off`, `host_asset`, `no_asset`, `vetoed`, and from 1.5.0 `gated` — a title the host sells, which an anonymous store cannot download; the record then carries its `purchase_url`) and a sentence. A client can grey those out instead of hiding them, which is what the WarpEngine Client does in its own port. Titles the store *chooses* not to offer are in neither list — a wrong status or an exclude list is editorial, not a limit of the machine.

**`fetch_catalog`** asks the host to filter by `owner_id` and `catalog.statuses` itself (`status=released,archived`), and follows `nextPage` if the host pages the answer anyway — so a host-side page size can never quietly cut a sync short. Both only shorten the download: `survey_catalog` applies the same filters again, so a host that ignores a parameter changes nothing.

**`download_url`** points at `GET /api/download` rather than `/file/`, so the host sees every download. Whether it counts them is the host's call: teletypegames.org deliberately leaves the `warpstore/*` user agent (`user_agent`) out of its download counts (`ignored_user_agents`), so installs by a store do not show up in the statistics.

**`resolve_for_host(value, host)`** — a config value that may depend on the machine. A plain string is the same everywhere, which is what a `cartridge` is: data for an emulator. Otherwise the value is a map and the most specific key wins:

```python
{"linux-aarch64": …, "aarch64": …, "linux": …, "*": …}
```

No matching key and no `*` gives `None`, and the caller reports the title as skipped instead of installing something that cannot run. This generalises the Batocera engine's `resolve_for_arch`: Batocera only ever answered `linux`, but a RetroArch host may be Windows, macOS or Android — which decides what a libretro core file is called (`.dll`, `.dylib`, `_android.so`).

**`within(path, root)`** — guards every delete. A store may only remove files from the subtree it owns, never from the user's library and never from another store's.

**`prune_empty_dirs(dirs, root)`** — the uninstall side of the same rule. Only empty directories go, and only inside `root`: one stray file keeps a directory. This is what lets `purge` (and so `uninstall.sh`) leave the host as it was.

**`write_atomic`** — nothing is written in place. A store interrupted mid-sync would otherwise leave a half-written playlist or gamelist, which is worse than an old one.

# Taking every store off a machine

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

Use this to strip the whole framework without remembering what is installed: it finds every store home under the known roots, has each store's **own engine** purge what it installed, then deletes the store, its launcher and the engine files. For a single store, prefer that engine's own `uninstall.sh` — it knows the host extras, like a Batocera Ports entry.

| Environment variable | Meaning |
|---|---|
| `DRY_RUN` | `1` prints what would go and removes nothing |
| `KEEP_GAMES` | `1` removes only the scripts, keeps the games |
| `FORCE` | `1` purges even while RetroArch is running |
| `BATOCERA_STORE_ROOT`, `BATOCERA_PORTS_DIR` | where to look on a Batocera box |
| `STORE_ROOT`, `BIN_DIR` | where to look on a desktop |

If an engine cannot finish (RetroArch running, ROMs root unmounted) the run stops there, rather than deleting the engine that knows what it installed.

The script is POSIX `sh`, not bash, so `| sh` works where `/bin/sh` is dash. The engines' own installers are too.

# State

`state.json`, version 2, keyed `<scope>:<name>`:

```json
{
  "version": 2,
  "installed": {
    "c64:blessingofra": { "name": "blessingofra", "scope": "c64", "asset": "blessingofra-2.0.0.prg", "…": "…" }
  }
}
```

`record_scope()` falls back to a record's `system` field, which is what the Batocera store wrote before this module existed. There the two were the same string, so an installed box keeps working with no migration and re-downloads nothing. A `version: 1` file, keyed by bare software name, is re-keyed on first run.

# Versioning

The engines fetch `warpstore.py` from wherever their installer's `WARPSTORE_RAW_BASE` points — `master` by default, so a change here reaches a store on its next install, which is also the reason to keep the surface small. A store repository may pin it instead: the [TTG Batocera Store](https://wiki.teletypegames.org/stores/ttg-batocera-store) fetches both its engine and `warpstore` at fixed commits, and bumps them after a release. `--version` on either engine prints both numbers, e.g. `3.1.0 (warpstore 1.2.0)`.

'; 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: [Engines](https://wiki.teletypegames.org/engines.md)
- Previous: [WarpEngine](https://wiki.teletypegames.org/engines/warp-engine.md)
