Files
mr.zeroandClaude Opus 5 ce210edee1 Let a store bring its own carousel logo
The menu entry borrowed the Ports icon, which is what "give it a theme
folder that already has artwork" buys you. EmulationStation offers nothing
better on its own: a system's logo is resolved through the theme
(SystemData::getProperty("image") -> theme's system/logo, path built from
the system's theme folder), and there is no user-level override anywhere —
not in the settings, not in the ROM folder, not in the binary.

So `menu.logo` installs the file where the themes look. The logo folder is
found rather than assumed: it is the folder holding the most logos of
systems every theme has (ports, snes, nes, …), because themes keep them in
art/logos, _inc/systems/logos, _art/Colorlogos and worse. On the box that
found 7 of 9 themes; the other two do not use ${system.theme} at all, and
there ES falls back to drawing the store's name as text, which is a fine
answer.

Two safeguards, since this writes outside the store's own folder for the
first time: the file is named after `menu.theme`, which now defaults to the
store id so no theme's own artwork can be overwritten, and every path
installed is recorded in state.json — an uninstall removes exactly those,
a pre-existing file of that name is reported and left alone.

Themes that ship with the system image are under /usr/share, and Batocera's
root is a RAM overlay: the copy works but does not survive a reboot. The
sync puts it back, and the log says so, naming batocera-save-overlay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 21:46:08 +02:00

487 lines
23 KiB
Markdown

# warp-engine-batocera-store — a Batocera store engine for WarpEngine sites
Pulls games from a [WarpEngine](https://git.teletypegames.org/tools/warp_engine)
catalog straight onto a Batocera box, with EmulationStation metadata and box art,
and gives the store **its own entry in the EmulationStation menu**. Started from
that entry on the device, or from the CLI over SSH.
This repository is the engine only. It knows the WarpEngine API but nothing
about any particular site: the host, the store's name and where its games land
all come from a `config.json` that lives in a separate **store repository**.
The part that is not about Batocera — catalog, releases, host matching, state,
HTTP — is [`warpstore`](https://git.teletypegames.org/engines/warpstore), shared
with the [RetroArch engine](https://git.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://git.teletypegames.org/stores/ttg-batocera-store).
```
"<store name>" ▸ "Update <store name>"
├─ GET /api/software the whole catalog
├─ keep platforms this box can run c64 → c64, 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
└─ install the logo into the themes so the entry is not borrowing one
```
Python 3 standard library only — Batocera ships python3 and no pip.
## 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 here. 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 is
borrowing — a file Batocera generated for this particular image, so it lists
exactly the emulators the box has. The one thing that is 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, and a game of ours is launched exactly as the box would launch it, with
the emulator the user configured for that system.
Five details worth knowing, all of them EmulationStation's, and the middle two
were learned the hard way:
- **The menu entry is declared, not implied.** ES would invent the group parent
by itself, but then its theme folder would be the group's name, which no theme
has — and `HideUniqueGroups` (on by default) dissolves a group that has a
single system in it unless a system by that name exists. The declared parent
fixes both: it carries a `<theme>` (`ports` by default, since every theme has
that one) 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 just hidden:
`loadSystem` logs `System "..." has no games! Ignoring it.` and throws it away.
So the menu entry cannot be an empty shelf — it *is* the `store/` folder, and
the updater in it is the game that keeps it alive. Which is also why the
*Update …* item sits directly in the entry rather than in a subfolder.
- **The entry must not be the directory above the systems.** ES marks a
directory it has enumerated with a `<dir>/*` entry in its file cache, and adds
that 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.
`store/` is their sibling, so there is no race to lose.
- **An empty system does not show.** Only the platforms that actually have
something installed become folders, so the entry is never full of dead ends.
- **A restart is needed for a new system.** The engine restarts ES itself after a
sync that added one.
The store id names the entry. If the box already has a system by that name — 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 would *modify that
system* rather than add 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, for instance,
tries `art/logos/${system.theme}.png`, then `.svg`. There is no user-level
override anywhere, so a custom system has exactly two options: borrow a theme
folder that already has artwork, or put a file where the themes look.
Both are supported. With no `menu.logo`, the entry borrows `ports` — every theme
has that one, so the entry has *an* icon, just not the store's. Set `menu.logo`
to an image (a path or a URL, or a list of them for several formats) and the
engine installs it instead:
- it finds each theme's logo folder by looking for the logos of systems every
theme has (`ports`, `snes`, `nes`, …) rather than by knowing the layouts —
themes keep them in `art/logos`, `_inc/systems/logos`, `_art/Colorlogos`, and
worse;
- the file is written as `<menu.theme>.<ext>`, and `menu.theme` then defaults to
the store id, so nothing of the theme's own is ever overwritten;
- **a file we did not write is never touched** — every path installed is recorded
in `state.json`, an uninstall takes exactly those back out, and a name clash is
reported and skipped;
- a theme with no logo folder we can find, or that resolves logos some other way,
shows the store's **name as text** instead — EmulationStation's own fallback
(`CarouselComponent`: no image, so a `TextComponent`).
One Batocera wrinkle: themes that ship *with the system image* live under
`/usr/share`, and Batocera's root is a RAM overlay. The copy works and shows up
immediately, but it is gone after a reboot — the next sync puts it back, and
`batocera-save-overlay` makes it permanent. Themes under `/userdata/themes` keep
it for good.
For the old behaviour — games inside the box's own systems, under a subfolder,
merged into the box's gamelists — set `emulationstation.menu.mode` to `merge`.
## 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. Restart EmulationStation
(`batocera-es-swissknife --restart`) to see it.
It installs two files: `store.py` from this repository and `warpstore.py` from
the [shared core](https://git.teletypegames.org/engines/warpstore). They have to
sit next to each other — the engine imports the core from its own directory.
Installer knobs, all environment variables: `STORE_CONFIG` (required),
`BATOCERA_STORE_ROOT`, `BATOCERA_PORTS_DIR`, `BATOCERA_PORT_NAME`,
`ENGINE_RAW_BASE`, `WARPSTORE_RAW_BASE`, and `WARPSTORE_SRC` to install a local
`warpstore.py` instead of downloading it.
## Upgrading a store that was installed before the menu entry
Engine 4.0 is the layout change, so the first sync after the upgrade moves the
store off the box's own systems. It does that by **removing the old install and
downloading again**, not by carrying files across: the ROMs, box art and port
payloads go, our nodes come out of the box's gamelists, the empty folders are
swept, the Ports entry is taken away — and then the sync fills the store's own
folder. The catalog is small enough that this is minutes, and there is no
half-moved state to end up in.
What is kept: your gamelists (only our nodes go — play counts and favourites of
your own games are untouched), and the `gamelist.xml.<id>-backup` copies, which
are listed at the end of the run for you to delete or keep.
`-n` shows the whole thing without doing any of it. To stay on the old layout,
set `emulationstation.menu.mode` to `merge` before the first sync.
## Writing a store repository
Four small 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
├── uninstall.sh the same for the engine's uninstaller
└── README.md
```
`install.sh` is small enough to quote in full:
```sh
#!/bin/bash
set -euo pipefail
ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master}"
export STORE_CONFIG="${STORE_CONFIG:-https://git.example.org/tools/my-batocera-store/raw/branch/master/config.json}"
curl -fsSL "$ENGINE_RAW_BASE/install.sh" | sh
```
`uninstall.sh` is the same shape, except that it passes `STORE_ID` — no config
fetch is needed to take a store away, and the id is what names its home:
```sh
export STORE_ID="${STORE_ID:-example}"
curl -fsSL "$ENGINE_RAW_BASE/uninstall.sh" | sh
```
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\>"*. It
downloads anything new, then restarts EmulationStation so the games show up. ES
gives a launched script 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'
```
or `./uninstall.sh` from a checkout on the box. It removes the games first,
through the engine — `purge` — and only then the launcher, any Ports entry and
the store home: the shell script has no way of knowing which ROMs, box art, port
payloads, gamelist entries and ES systems were the store's, and the engine's
`state.json` does. If the engine cannot finish — an unmounted ROMs root, say —
nothing else is touched, so you are never left with the files but not the engine
that knows about 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 an uninstall is total: the store's ROM folder, its
gamelists, its `es_systems_<id>.cfg` and the logos it installed into the themes
all go, and the box is left as it was.
In `merge` mode there is more to be careful about, and these stay behind:
- **ROMs you put in the store's subfolder yourself.** Directories are removed
only when empty, so one stray file keeps its directory. `ports/.data` itself
also stays: another store may still keep a payload there.
- **your gamelists.** They belong to the box: our `<game>` and `<folder>` nodes
go, the rest — your own games, their play counts and favourites — is written
back untouched. A gamelist we *created* and that is left empty is deleted,
since it was never yours.
- **`gamelist.xml.<store id>-backup`**, listed at the end of the run: your
gamelists as we first found them. Deleting them is your call.
A dry run cannot predict which directories will end up empty, so it lists the
files but not the directory removals.
To take **every** store off a machine — both engines, the shared core, the
launchers — there is one script for that in
[`warpstore`](https://git.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
/userdata/roms/example/ the store's own ROM folder
├── c64/
│ ├── blessingofra-2.0.0.prg, rabbit-1.0.0.prg, …
│ ├── images/blessingofra.png, …
│ └── gamelist.xml
├── ports/
│ ├── <name>.sh the launchers ES lists
│ ├── images/<name>.png
│ ├── .data/<name>/ the unpacked programs
│ └── 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, which is what makes a prune
safe: nothing outside the store's folder can be reached, and inside it only what
`state.json` says we installed is ever deleted — a ROM you drop in there
yourself keeps its file *and* its gamelist entry.
In `merge` mode the same content goes into `<roms_root>/<system>/<subfolder>/`
and our nodes are merged into the box's `gamelist.xml`. The first time one is
touched it is copied to `gamelist.xml.<store id>-backup`; on every merge only
`<game>`/`<folder>` nodes under that store's subfolder are rewritten, so your own
entries keep their play counts, favourites and scraped media, and two stores can
share one gamelist.
Either way, the tags EmulationStation owns — `favorite`, `playcount`,
`lastplayed`, `rating` — are carried over onto the node we rewrite. A favourite
stays a favourite across a sync.
## Configuration
`config.json` in the store home, template in `config.example.json`:
| Key | Default | Meaning |
| --- | --- | --- |
| `store.id` | `warp` | slug: names the store home, the log prefix, the menu entry and its `es_systems` file |
| `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 name of the logo file |
| `emulationstation.menu.logo` | `null` | the entry's own logo: a path or URL, or a list of them (one per format). 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, `true` in `merge` mode | also write a Ports entry that runs the sync |
| `emulationstation.folder_name` | `WarpEngine Store` | `merge` mode only: display name of our folder inside the box's gamelist; `null` hides it |
| `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, tic80 | platform → system, asset kind, extension, enabled; `install` picks ROM or Port, and the kind may be per-architecture |
| `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) |
`BATOCERA_ES_CONFIG_DIR` and `BATOCERA_ES_SYSTEMS_CFG` override the two
EmulationStation paths from the environment, which is what makes a staging root
testable off a real box.
### Platform mapping
A catalog platform is installable when its release asset is a file a system this
box has can boot directly:
| Catalog platform | Asset kind | Batocera system | Extension |
| --- | --- | --- | --- |
| `c64` | `cartridge` | `c64` (VICE) | `.prg` |
| `tic80` | `cartridge` | `tic80` | `.tic` |
"Has" means declared in the box's own `es_systems.cfg` — an emulator that got
built into this image. (A ROM folder is a poorer test: it can be there with
nothing behind it. The folders are the fallback when the file cannot be read.)
The other WarpEngine platforms (`ebitengine`, `love`, `godot`, `bevy`,
`phaser`) ship `html` and per-OS zip archives, not a ROM a Batocera 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 } }
```
### Native games as Ports
A cartridge is booted by an emulator, so it goes to the folder of the system that
boots it. A native build is a program: Batocera runs Linux, and its **Ports**
system takes `.sh` launchers. Set `install` to `port` and point `system` at
`ports`:
```json
"platforms": {
"bevy": {
"system": "ports",
"install": "port",
"kind": { "x86_64": "linux_x64", "aarch64": "linux_arm64" },
"ext": ".zip",
"enabled": true
}
}
```
The zip is unpacked and a launcher written:
```
/userdata/roms/<subfolder>/ports/<name>.sh the launcher ES lists
/userdata/roms/<subfolder>/ports/images/<name>.png box art
/userdata/roms/<subfolder>/ports/.data/<name>/ the unpacked program
```
The payload sits under `.data`, whose leading dot is what hides it from
EmulationStation — only the `.sh` next to it is meant to be listed. `system:
"ports"` still names where the launcher *definition* comes from: our own ports
system copies the box's, which is `sh`, so the launcher is simply run.
The launcher `cd`s into the directory holding the executable before running it:
a game loads its `assets/` relative to the working directory. Python's zip
extraction drops the executable bit, so it is restored from the archive's
recorded mode.
`install` defaults to `rom`, so a config that does not mention it behaves exactly
as before.
### Per-architecture assets
Batocera runs on x86_64 mini-PCs, on the Raspberry Pi and on the ARM handhelds
alike, and a native binary only starts on the architecture it was built for —
an x86_64 build installs on a Pi and then does nothing. A cartridge is data for
an emulator, so it is architecture-independent; a native build is not.
Where the right asset depends on the machine, `kind` (and `ext`, if it differs)
can be a map instead of a string. `*` is the fallback:
```json
"platforms": {
"bevy": {
"system": "bevy",
"kind": { "x86_64": "linux_x64", "aarch64": "linux_arm64" },
"ext": ".zip",
"enabled": true
}
}
```
The engine reports the architecture it detected — `x86_64`, `aarch64`, `armhf`,
`x86` — at the top of `list`, and `config` prints it as `detected_arch`. With no
entry for the running machine and no `*`, the title is skipped and says so:
`skipped bevydemo: bevy has no asset kind for aarch64`.
A platform is also skipped when the box does not have its system —
`list` reports that as `skipped <name>: this box has no '<system>' system`.
## How a sync stays safe next to EmulationStation
EmulationStation keeps gamelists in memory and writes them back when it exits,
so a merge done while it runs can be clobbered. Launched from the menu
(`sync-from-es`), the engine therefore downloads first, then hands the gamelist
merge to a detached `apply-gamelists --wait-pid <es-pid>` child that waits for
the old ES process to die before writing. From SSH, with no ES running, the
merge happens inline.
The `es_systems_<id>.cfg` needs none of that: ES reads it and never writes it,
so it is written straight away — it just does not take effect until ES restarts.
Re-running a sync is idempotent: files already present 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. `GET /api/download` is used rather than `/file/`, so downloads count
towards the catalog's statistics.
## State
`state.json` records what this 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, which is how the upgrade to the store's
own folder knows it has to run once. A `version: 1` state file written by the
pre-split `ttg-store` is migrated to the current key format on first run.