Files
mr.zeroandClaude Opus 5 9c185e8fa9 Keep the titles a store cannot install
`select_games` answers what a store can install and throws the rest away, which is fine
for installing and wrong for showing: a client that hides what it cannot install leaves
the visitor wondering whether the catalog is small or their machine is unusual.

`survey_catalog` is the same walk with those titles kept — one record per title, with a
reason code (platform_off, host_asset, no_asset, vetoed), a sentence, and enough of the
catalog entry to draw a card. `select_games` is now a two-value wrapper around it, so an
adapter that only installs needs no change; the Batocera and RetroArch stores are
untouched.

Titles the store *chooses* not to offer stay invisible: the wrong status or an
only/exclude list is editorial, not a limitation of the machine. A platform switched off
is reported, because to somebody reading a catalog it says "not supported here".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 19:45:09 +02:00

170 lines
7.8 KiB
Markdown

# warpstore — the shared core of the WarpEngine store engines
A **store engine** reads a [WarpEngine](https://git.teletypegames.org/engines/warp_engine)
catalog and writes some host platform's own game library format. Only that last
step differs between hosts. Everything before it — talking to the API, picking
which release to install, matching the machine, remembering what was put where —
is this module.
```
┌──────────────────────────────┐
│ 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
```
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 has to supply
Three things, and nothing else:
1. **A `DEFAULT_CONFIG`** describing its host — where things live, which catalog
platform maps to what.
2. **An `accept(spec, sw)` callback** deciding which catalog entries that 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. It is what `state.json` is keyed by (`<scope>:<name>`), so two hosts
can carry a game of the same name without colliding.
### Listing what cannot be installed
`select_games` answers what a store can install. `survey_catalog` answers the same
question and keeps the rest, which is what a client needs to show a catalog honestly:
```python
games, skipped, unavailable = ws.survey_catalog(cfg, catalog, accept)
```
Each `unavailable` record carries `name`, `title`, `platform`, `desc`, `author`,
`image_url`, the latest `version`, a `reason` code and a `detail` sentence. The codes:
| Code | Meaning |
|---|---|
| `platform_off` | this store does not carry that platform at all |
| `host_asset` | the platform has no asset kind for this OS and architecture |
| `no_asset` | no release carries the asset it would need |
| `vetoed` | the adapter's `accept` refused it |
Titles the store *chooses* not to offer — the wrong status, an `only`/`exclude` list —
are in none of the three lists: that is editorial, not a limitation of the machine. A
platform switched off is reported, because to somebody looking at a catalog it reads as
"not supported here". `select_games` is now a two-value wrapper around this, so an
adapter that only installs needs no change.
## What is in here
| 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` |
Four of them carry decisions worth knowing about.
**`resolve_for_host(value, host)`** — a config value that may depend on the
machine. A plain string is the same everywhere (that is what a `cartridge` is:
data for an emulator). Where it differs, the value is a map and the most specific
key wins: `{"linux-aarch64": …, "aarch64": …, "linux": …, "*": …}`. With no
matching key and no `*` the answer is `None`, and the caller reports the title as
skipped rather than installing something that cannot run.
**`within(path, root)`** — every delete a store performs is guarded by this. A
store may only remove files from the subtree it owns, never from the user's own
library and never from another store's.
**`prune_empty_dirs(dirs, root)`** — the uninstall side of the same rule. A store
that has removed everything should not leave its folders behind, but only empty
directories go, and only inside `root`: one surprise file is enough to keep a
directory.
**`write_atomic`** — nothing is ever 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
```
A single store is better removed by its own engine's `uninstall.sh`, which knows
that host's extras — a Batocera Ports entry, say. This one is for taking the
whole framework off a machine without having to remember what is installed: it
finds every store home under the known roots, has each store's **own engine**
purge what it installed, and then deletes the store, its launcher and the engine
files.
| Environment variable | Meaning |
|---|---|
| `DRY_RUN` | `1` to print what would go and remove nothing |
| `KEEP_GAMES` | `1` to remove only the scripts and leave the installed games |
| `FORCE` | `1` to purge 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 any engine cannot finish — RetroArch running, a 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 on a machine whose `/bin/sh`
is dash. The engines' own installers and uninstallers 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 — and there the two were the same
string, so an installed box keeps working with no migration. A `version: 1` file,
keyed by bare software name, is re-keyed on first run.
## Users
- [`warp-engine-batocera-store`](https://git.teletypegames.org/stores/warp-engine-batocera-store) — EmulationStation ROM folders, `gamelist.xml`, Ports launchers
- [`warp-engine-retroarch-store`](https://git.teletypegames.org/stores/warp-engine-retroarch-store) — RetroArch `.lpl` playlists and thumbnail folders
Both pin nothing: they fetch `warpstore.py` from this repository's `master` at
install time. A change here therefore reaches every store on its next install —
which is the point, and also the reason to keep the surface above small.