Give the store its own EmulationStation menu entry
The games were scattered across the box's own systems — a folder inside c64, another inside tic80, the launchers inside Ports, and the sync a fourth entry somewhere else. There was no one place where the store appeared, and living in the box's folders is what forced the gamelist merging, the backups and every "never step outside your subfolder" check. Now the store gets a folder of its own, /userdata/roms/<subfolder>, with a directory per platform, and an es_systems_<id>.cfg overlay declares one ES system per platform under a shared <group> — which EmulationStation draws as a single carousel entry holding a folder per system. The gamelists are ours, the box's systems are untouched, and the sync is startable from inside the entry: Store -> "Update <store>". Nothing about emulators is hardcoded. The launch command, the extensions, the platform, the theme and the emulator list are copied off the box's own es_systems.cfg — a file Batocera generated for this image, so it lists exactly what got built into it. %SYSTEM% is resolved to the source system name on the way, since ES would otherwise substitute ttg-c64, which configgen has never heard of. The old behaviour is emulationstation.menu.mode: "merge", and switching either way migrates: the layout the files are in is recorded in state.json, and a mismatch removes the old one and downloads again rather than shuffling files half-way. purge follows that record too, so an uninstall after an upgrade whose first sync never ran still finds the games where they actually are. Every path now goes through a Layout, so the two modes cannot drift apart. Also: a gamelist or es_systems file whose content has not changed is not rewritten at all, and the tags ES owns — favourite, playcount, lastplayed — are carried over onto the nodes we rewrite, which merge mode used to lose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
# warp-engine-batocera-store — a Batocera store engine for WarpEngine sites
|
# warp-engine-batocera-store — a Batocera store engine for WarpEngine sites
|
||||||
|
|
||||||
Pulls games from a [WarpEngine](https://git.teletypegames.org/tools/warp_engine)
|
Pulls games from a [WarpEngine](https://git.teletypegames.org/tools/warp_engine)
|
||||||
catalog straight into a Batocera box's ROM folders, with EmulationStation
|
catalog straight onto a Batocera box, with EmulationStation metadata and box art,
|
||||||
metadata and box art. Triggered from the **Ports** menu on the device, or from
|
and gives the store **its own entry in the EmulationStation menu**. Started from
|
||||||
the CLI over SSH.
|
that entry on the device, or from the CLI over SSH.
|
||||||
|
|
||||||
This repository is the engine only. It knows the WarpEngine API but nothing
|
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
|
about any particular site: the host, the store's name and where its games land
|
||||||
@@ -31,18 +31,65 @@ The reference store is
|
|||||||
[`ttg-batocera-store`](https://git.teletypegames.org/stores/ttg-batocera-store).
|
[`ttg-batocera-store`](https://git.teletypegames.org/stores/ttg-batocera-store).
|
||||||
|
|
||||||
```
|
```
|
||||||
Ports ▸ "<store name>"
|
"<store name>" ▸ Store ▸ "Update <store name>"
|
||||||
│
|
│
|
||||||
├─ GET /api/software the whole catalog
|
├─ GET /api/software the whole catalog
|
||||||
├─ keep platforms this box can run c64 → c64, tic80 → tic80
|
├─ keep platforms this box can run c64 → c64, tic80 → tic80
|
||||||
├─ pick the newest non-dev release that carries the right asset
|
├─ pick the newest non-dev release that carries the right asset
|
||||||
├─ GET /api/download?path=<asset> .prg / .tic into the ROM folder
|
├─ GET /api/download?path=<asset> .prg / .tic into the store's folder
|
||||||
├─ GET <software.imageUrl> box art
|
├─ GET <software.imageUrl> box art
|
||||||
└─ merge gamelist.xml title, desc, author, image
|
├─ write gamelist.xml title, desc, author, image
|
||||||
|
└─ write es_systems_<id>.cfg the store's menu entry
|
||||||
```
|
```
|
||||||
|
|
||||||
Python 3 standard library only — Batocera ships python3 and no pip.
|
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
|
||||||
|
└ Store → "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.
|
||||||
|
|
||||||
|
Three details worth knowing, all of them EmulationStation's:
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
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
|
## Installing a store
|
||||||
|
|
||||||
The installer takes the store as a parameter, so it works with any config:
|
The installer takes the store as a parameter, so it works with any config:
|
||||||
@@ -60,9 +107,9 @@ ssh root@batocera 'STORE_CONFIG=/tmp/my-config.json /tmp/warp-engine-batocera-st
|
|||||||
```
|
```
|
||||||
|
|
||||||
It reads `store.id` from the config, installs the engine and the config into
|
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 a
|
`/userdata/system/batocera-store/<id>/`, writes a `<id>-store` launcher, and runs
|
||||||
Ports entry, and runs the first sync. Restart EmulationStation
|
the first sync — which is what creates the menu entry. Restart EmulationStation
|
||||||
(`batocera-es-swissknife --restart`) to see the games.
|
(`batocera-es-swissknife --restart`) to see it.
|
||||||
|
|
||||||
It installs two files: `store.py` from this repository and `warpstore.py` from
|
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
|
the [shared core](https://git.teletypegames.org/engines/warpstore). They have to
|
||||||
@@ -73,6 +120,23 @@ Installer knobs, all environment variables: `STORE_CONFIG` (required),
|
|||||||
`ENGINE_RAW_BASE`, `WARPSTORE_RAW_BASE`, and `WARPSTORE_SRC` to install a local
|
`ENGINE_RAW_BASE`, `WARPSTORE_RAW_BASE`, and `WARPSTORE_SRC` to install a local
|
||||||
`warpstore.py` instead of downloading it.
|
`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
|
## Writing a store repository
|
||||||
|
|
||||||
Four small files:
|
Four small files:
|
||||||
@@ -92,7 +156,6 @@ my-batocera-store/
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master}"
|
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}"
|
export STORE_CONFIG="${STORE_CONFIG:-https://git.example.org/tools/my-batocera-store/raw/branch/master/config.json}"
|
||||||
export BATOCERA_PORT_NAME="My Store"
|
|
||||||
curl -fsSL "$ENGINE_RAW_BASE/install.sh" | sh
|
curl -fsSL "$ENGINE_RAW_BASE/install.sh" | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -105,13 +168,15 @@ curl -fsSL "$ENGINE_RAW_BASE/uninstall.sh" | sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
Pick a `store.id` and a `paths.subfolder` nobody else uses — they are what keeps
|
Pick a `store.id` and a `paths.subfolder` nobody else uses — they are what keeps
|
||||||
two stores on the same box from deleting each other's games.
|
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
|
## Use
|
||||||
|
|
||||||
**On the device.** *Ports ▸ "\<store name\>"*. The script downloads anything
|
**On the device.** *"\<store name\>" ▸ Store ▸ "Update \<store name\>"*. It
|
||||||
new, then restarts EmulationStation so the games show up. Ports scripts get no
|
downloads anything new, then restarts EmulationStation so the games show up. ES
|
||||||
console in Batocera, so the output goes to `store.log` in the store home.
|
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:
|
**Over SSH**, through the launcher the installer wrote:
|
||||||
|
|
||||||
@@ -119,12 +184,12 @@ console in Batocera, so the output goes to `store.log` in the store home.
|
|||||||
S=/userdata/system/batocera-store/example-store
|
S=/userdata/system/batocera-store/example-store
|
||||||
|
|
||||||
$S list # compatible catalog entries; * installed, ^ update available
|
$S list # compatible catalog entries; * installed, ^ update available
|
||||||
$S sync # download everything new, refresh gamelists
|
$S sync # download everything new, refresh gamelists and the menu
|
||||||
$S sync blessingofra # just one title (never prunes)
|
$S sync blessingofra # just one title (never prunes)
|
||||||
$S -n sync # dry run
|
$S -n sync # dry run
|
||||||
$S remove c64:demo # uninstall (bare name works too)
|
$S remove c64:demo # uninstall (bare name works too)
|
||||||
$S purge # uninstall everything this store installed
|
$S purge # uninstall everything this store installed
|
||||||
$S config # effective configuration
|
$S config # effective configuration, and where the menu entry lives
|
||||||
```
|
```
|
||||||
|
|
||||||
Global flags go **before** the subcommand: `$S --roms-root /tmp/roms sync`.
|
Global flags go **before** the subcommand: `$S --roms-root /tmp/roms sync`.
|
||||||
@@ -136,12 +201,12 @@ ssh root@batocera 'curl -fsSL https://git.teletypegames.org/stores/warp-engine-b
|
|||||||
```
|
```
|
||||||
|
|
||||||
or `./uninstall.sh` from a checkout on the box. It removes the games first,
|
or `./uninstall.sh` from a checkout on the box. It removes the games first,
|
||||||
through the engine — `purge` — and only then the Ports entry, the launcher and the
|
through the engine — `purge` — and only then the launcher, any Ports entry and
|
||||||
store home: the shell script has no way of knowing which ROMs, box art, Ports
|
the store home: the shell script has no way of knowing which ROMs, box art, port
|
||||||
payloads and gamelist entries were the store's, and the engine's `state.json`
|
payloads, gamelist entries and ES systems were the store's, and the engine's
|
||||||
does. If the engine cannot finish — an unmounted ROMs root, say — nothing else is
|
`state.json` does. If the engine cannot finish — an unmounted ROMs root, say —
|
||||||
touched, so you are never left with the files but not the engine that knows about
|
nothing else is touched, so you are never left with the files but not the engine
|
||||||
them.
|
that knows about them.
|
||||||
|
|
||||||
| Environment variable | Meaning |
|
| Environment variable | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -151,11 +216,19 @@ them.
|
|||||||
| `KEEP_HOME` | `1` to keep the store home (state, catalog cache, log) for a reinstall |
|
| `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 |
|
| `BATOCERA_STORE_ROOT`, `BATOCERA_PORTS_DIR`, `BATOCERA_PORT_NAME` | as for the installer |
|
||||||
|
|
||||||
What it deliberately leaves behind:
|
In the store's own layout an uninstall is total: the store's ROM folder, its
|
||||||
|
gamelists and its `es_systems_<id>.cfg` 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.
|
- **ROMs you put in the store's subfolder yourself.** Directories are removed
|
||||||
- **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.
|
only when empty, so one stray file keeps its directory. `ports/.data` itself
|
||||||
- **`gamelist.xml.<store id>-backup`**, listed at the end of the run: your gamelists as we first found them. Deleting them is your call.
|
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
|
A dry run cannot predict which directories will end up empty, so it lists the
|
||||||
files but not the directory removals.
|
files but not the directory removals.
|
||||||
@@ -178,18 +251,38 @@ curl -fsSL https://git.teletypegames.org/engines/warpstore/raw/branch/master/uni
|
|||||||
├── config.json the store that selected it
|
├── config.json the store that selected it
|
||||||
└── state.json, catalog.json, store.log
|
└── state.json, catalog.json, store.log
|
||||||
|
|
||||||
/userdata/roms/c64/example/ blessingofra-2.0.0.prg, rabbit-1.0.0.prg, …
|
/userdata/system/configs/emulationstation/es_systems_example.cfg the menu entry
|
||||||
/userdata/roms/c64/example/images/ blessingofra.png, …
|
|
||||||
/userdata/roms/c64/gamelist.xml our entries merged in
|
/userdata/roms/example/ the store's own ROM folder
|
||||||
/userdata/roms/ports/Example Store.sh
|
├── 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/
|
||||||
|
├── update.sh the sync, as the menu starts it
|
||||||
|
└── gamelist.xml
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything a store installs lives under its own `paths.subfolder`, so a prune
|
The box's own system folders are not touched at all, which is what makes a prune
|
||||||
can never reach ROMs you put there yourself — or another store's games. The
|
safe: nothing outside the store's folder can be reached, and inside it only what
|
||||||
first time a `gamelist.xml` is touched it is copied to
|
`state.json` says we installed is ever deleted — a ROM you drop in there
|
||||||
`gamelist.xml.<store id>-backup`; on every merge only `<game>`/`<folder>` nodes
|
yourself keeps its file *and* its gamelist entry.
|
||||||
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.
|
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
|
## Configuration
|
||||||
|
|
||||||
@@ -197,15 +290,22 @@ counts, favourites and scraped media, and two stores can share one gamelist.
|
|||||||
|
|
||||||
| Key | Default | Meaning |
|
| Key | Default | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `store.id` | `warp` | slug: names the store home, log prefix and gamelist backup |
|
| `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 for the Ports entry |
|
| `store.name` | `WarpEngine Store` | display name of the menu entry |
|
||||||
| `store.base_url` | — | WarpEngine host |
|
| `store.base_url` | — | WarpEngine host |
|
||||||
| `store.api.catalog` | `/api/software` | catalog endpoint, if the engine is mounted elsewhere |
|
| `store.api.catalog` | `/api/software` | catalog endpoint, if the engine is mounted elsewhere |
|
||||||
| `store.api.download` | `/api/download` | download endpoint |
|
| `store.api.download` | `/api/download` | download endpoint |
|
||||||
| `paths.roms_root` | `/userdata/roms` | where systems live |
|
| `paths.roms_root` | `/userdata/roms` | where systems live |
|
||||||
| `paths.subfolder` | `warp` | this store's subfolder inside each system |
|
| `paths.subfolder` | `warp` | the store's own folder inside `roms_root` (in `merge` mode: its subfolder inside each system) |
|
||||||
| `emulationstation.folder_name` | `WarpEngine Store` | display name of that folder in ES |
|
| `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` | `ports` | theme folder the entry borrows its logo from |
|
||||||
|
| `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.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.statuses` | `["released", "archived"]` | catalog `status` values to install |
|
||||||
| `catalog.owner_id` | `null` | restrict to one publisher (`/api/software?owner_id=`) |
|
| `catalog.owner_id` | `null` | restrict to one publisher (`/api/software?owner_id=`) |
|
||||||
| `catalog.only` / `catalog.exclude` | `[]` | software-name allow / deny lists |
|
| `catalog.only` / `catalog.exclude` | `[]` | software-name allow / deny lists |
|
||||||
@@ -214,16 +314,24 @@ counts, favourites and scraped media, and two stores can share one gamelist.
|
|||||||
| `behavior.timeout` | `30` | HTTP timeout, seconds |
|
| `behavior.timeout` | `30` | HTTP timeout, seconds |
|
||||||
| `behavior.insecure` | `false` | skip TLS verification (self-hosted test instances) |
|
| `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
|
### Platform mapping
|
||||||
|
|
||||||
A catalog platform is installable when its release asset is a file the Batocera
|
A catalog platform is installable when its release asset is a file a system this
|
||||||
system can boot directly:
|
box has can boot directly:
|
||||||
|
|
||||||
| Catalog platform | Asset kind | Batocera system | Extension |
|
| Catalog platform | Asset kind | Batocera system | Extension |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `c64` | `cartridge` | `c64` (VICE) | `.prg` |
|
| `c64` | `cartridge` | `c64` (VICE) | `.prg` |
|
||||||
| `tic80` | `cartridge` | `tic80` | `.tic` |
|
| `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`,
|
The other WarpEngine platforms (`ebitengine`, `love`, `godot`, `bevy`,
|
||||||
`phaser`) ship `html` and per-OS zip archives, not a ROM a Batocera system
|
`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:
|
launches, so they are not mapped by default. Adding one is a config edit:
|
||||||
@@ -234,9 +342,10 @@ launches, so they are not mapped by default. Adding one is a config edit:
|
|||||||
|
|
||||||
### Native games as Ports
|
### Native games as Ports
|
||||||
|
|
||||||
A cartridge is booted by an emulator, so it goes into that system's ROM folder.
|
A cartridge is booted by an emulator, so it goes to the folder of the system that
|
||||||
A native build is a program: Batocera runs Linux, and its **Ports** system takes
|
boots it. A native build is a program: Batocera runs Linux, and its **Ports**
|
||||||
`.sh` launchers. Set `install` to `port` and point `system` at `ports`:
|
system takes `.sh` launchers. Set `install` to `port` and point `system` at
|
||||||
|
`ports`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"platforms": {
|
"platforms": {
|
||||||
@@ -253,15 +362,15 @@ A native build is a program: Batocera runs Linux, and its **Ports** system takes
|
|||||||
The zip is unpacked and a launcher written:
|
The zip is unpacked and a launcher written:
|
||||||
|
|
||||||
```
|
```
|
||||||
/userdata/roms/ports/<subfolder>/<name>.sh the launcher ES lists
|
/userdata/roms/<subfolder>/ports/<name>.sh the launcher ES lists
|
||||||
/userdata/roms/ports/<subfolder>/images/<name>.png box art
|
/userdata/roms/<subfolder>/ports/images/<name>.png box art
|
||||||
/userdata/roms/ports/.data/<subfolder>/<name>/ the unpacked program
|
/userdata/roms/<subfolder>/ports/.data/<name>/ the unpacked program
|
||||||
```
|
```
|
||||||
|
|
||||||
The payload sits under `.data`, whose leading dot is what hides it from
|
The payload sits under `.data`, whose leading dot is what hides it from
|
||||||
EmulationStation — the launchers themselves are in a normal subfolder, scanned
|
EmulationStation — only the `.sh` next to it is meant to be listed. `system:
|
||||||
like any other system's. Both paths carry the store's subfolder, so an uninstall
|
"ports"` still names where the launcher *definition* comes from: our own ports
|
||||||
or a prune can only ever reach this store's own files.
|
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:
|
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
|
a game loads its `assets/` relative to the working directory. Python's zip
|
||||||
@@ -297,26 +406,31 @@ The engine reports the architecture it detected — `x86_64`, `aarch64`, `armhf`
|
|||||||
entry for the running machine and no `*`, the title is skipped and says so:
|
entry for the running machine and no `*`, the title is skipped and says so:
|
||||||
`skipped bevydemo: bevy has no asset kind for aarch64`.
|
`skipped bevydemo: bevy has no asset kind for aarch64`.
|
||||||
|
|
||||||
A platform is also skipped when the box has no ROM folder for its system —
|
A platform is also skipped when the box does not have its system —
|
||||||
`list` reports that as `skipped <name>: no '<system>' ROM folder`.
|
`list` reports that as `skipped <name>: this box has no '<system>' system`.
|
||||||
|
|
||||||
## How a sync stays safe next to EmulationStation
|
## How a sync stays safe next to EmulationStation
|
||||||
|
|
||||||
EmulationStation keeps gamelists in memory and writes them back when it exits,
|
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 Ports menu
|
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
|
(`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
|
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
|
the old ES process to die before writing. From SSH, with no ES running, the
|
||||||
merge happens inline.
|
merge happens inline.
|
||||||
|
|
||||||
Re-running a sync is idempotent: files already present are left alone, and a
|
The `es_systems_<id>.cfg` needs none of that: ES reads it and never writes it,
|
||||||
release bump (`1.1` → `2.0.0`) deletes the old asset before fetching the new
|
so it is written straight away — it just does not take effect until ES restarts.
|
||||||
one. `GET /api/download` is used rather than `/file/`, so downloads count
|
|
||||||
|
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.
|
towards the catalog's statistics.
|
||||||
|
|
||||||
## State
|
## State
|
||||||
|
|
||||||
`state.json` records what this store installed, keyed `<system>:<name>` so the
|
`state.json` records what this store installed, keyed `<system>:<name>` so the
|
||||||
same software name on two systems stays two entries. A `version: 1` state file
|
same software name on two systems stays two entries. `layout` says which
|
||||||
written by the pre-split `ttg-store` is migrated to that key format on first
|
directory layout those records belong to, which is how the upgrade to the store's
|
||||||
run.
|
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.
|
||||||
|
|||||||
+10
-1
@@ -13,8 +13,17 @@
|
|||||||
"subfolder": "example"
|
"subfolder": "example"
|
||||||
},
|
},
|
||||||
"emulationstation": {
|
"emulationstation": {
|
||||||
|
"menu": {
|
||||||
|
"mode": "system",
|
||||||
|
"name": null,
|
||||||
|
"theme": "ports",
|
||||||
|
"updater": true,
|
||||||
|
"labels": {}
|
||||||
|
},
|
||||||
|
"ports_entry": null,
|
||||||
"folder_name": "Example Store",
|
"folder_name": "Example Store",
|
||||||
"restart": true
|
"restart": true,
|
||||||
|
"config_dir": null
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"statuses": [
|
"statuses": [
|
||||||
|
|||||||
+25
-6
@@ -45,17 +45,26 @@ trap 'rm -f "$TMP_CFG"' EXIT
|
|||||||
say "reading store config from $STORE_CONFIG"
|
say "reading store config from $STORE_CONFIG"
|
||||||
fetch "$STORE_CONFIG" "$TMP_CFG"
|
fetch "$STORE_CONFIG" "$TMP_CFG"
|
||||||
|
|
||||||
|
# The store id names its home; the mode decides whether a Ports entry is wanted
|
||||||
|
# at all, since a store with its own ES menu entry has an updater inside it.
|
||||||
STORE_META="$(python3 -c '
|
STORE_META="$(python3 -c '
|
||||||
import json, sys
|
import json, sys
|
||||||
cfg = json.load(open(sys.argv[1])).get("store") or {}
|
cfg = json.load(open(sys.argv[1]))
|
||||||
sid = cfg.get("id") or ""
|
store = cfg.get("store") or {}
|
||||||
|
sid = store.get("id") or ""
|
||||||
if not sid or "/" in sid:
|
if not sid or "/" in sid:
|
||||||
sys.exit("store.id is required and must not contain a slash")
|
sys.exit("store.id is required and must not contain a slash")
|
||||||
print(sid, cfg.get("name") or sid)
|
es = cfg.get("emulationstation") or {}
|
||||||
|
mode = ((es.get("menu") or {}).get("mode") or "system").lower()
|
||||||
|
port = es.get("ports_entry")
|
||||||
|
port = (mode == "merge") if port is None else bool(port)
|
||||||
|
print(sid, "1" if port else "0", store.get("name") or sid)
|
||||||
' "$TMP_CFG")" || die "cannot read $STORE_CONFIG"
|
' "$TMP_CFG")" || die "cannot read $STORE_CONFIG"
|
||||||
|
|
||||||
STORE_ID="${STORE_META%% *}"
|
STORE_ID="${STORE_META%% *}"
|
||||||
STORE_NAME="${STORE_META#* }"
|
STORE_REST="${STORE_META#* }"
|
||||||
|
WANT_PORTS_ENTRY="${STORE_REST%% *}"
|
||||||
|
STORE_NAME="${STORE_REST#* }"
|
||||||
|
|
||||||
STORE_HOME="$STORE_ROOT/$STORE_ID"
|
STORE_HOME="$STORE_ROOT/$STORE_ID"
|
||||||
PORT_NAME="${BATOCERA_PORT_NAME:-$STORE_NAME}"
|
PORT_NAME="${BATOCERA_PORT_NAME:-$STORE_NAME}"
|
||||||
@@ -99,7 +108,10 @@ EOF
|
|||||||
chmod 0755 "$LAUNCHER"
|
chmod 0755 "$LAUNCHER"
|
||||||
|
|
||||||
# The Ports entry: EmulationStation gives launched scripts no console, so send
|
# The Ports entry: EmulationStation gives launched scripts no console, so send
|
||||||
# everything to a log file the user can read over SSH.
|
# everything to a log file the user can read over SSH. A store that has its own
|
||||||
|
# ES menu entry keeps the same script inside it instead — the engine writes that
|
||||||
|
# one — so there is nothing to put in Ports.
|
||||||
|
if [ "$WANT_PORTS_ENTRY" = "1" ]; then
|
||||||
cat > "$PORTS_DIR/$PORT_NAME.sh" <<EOF
|
cat > "$PORTS_DIR/$PORT_NAME.sh" <<EOF
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# $STORE_NAME — syncs the catalog into this box's ROM folders.
|
# $STORE_NAME — syncs the catalog into this box's ROM folders.
|
||||||
@@ -110,15 +122,22 @@ echo "=== \$(date -Iseconds) sync finished (exit \$?) ==="
|
|||||||
EOF
|
EOF
|
||||||
chmod 0755 "$PORTS_DIR/$PORT_NAME.sh"
|
chmod 0755 "$PORTS_DIR/$PORT_NAME.sh"
|
||||||
say "ports entry: $PORTS_DIR/$PORT_NAME.sh"
|
say "ports entry: $PORTS_DIR/$PORT_NAME.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
say "running the first sync"
|
say "running the first sync"
|
||||||
"$LAUNCHER" --verbose sync --no-restart
|
"$LAUNCHER" --verbose sync --no-restart
|
||||||
|
|
||||||
|
if [ "$WANT_PORTS_ENTRY" = "1" ]; then
|
||||||
|
MENU_LINE=" Menu: Ports -> \"$PORT_NAME\" (re-run any time to pull new releases)"
|
||||||
|
else
|
||||||
|
MENU_LINE=" Menu: \"$STORE_NAME\" -> Store -> \"Update $STORE_NAME\" (re-run any time to pull new releases)"
|
||||||
|
fi
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
||||||
[install] done.
|
[install] done.
|
||||||
|
|
||||||
Menu: Ports -> "$PORT_NAME" (re-run any time to pull new releases)
|
$MENU_LINE
|
||||||
CLI: $LAUNCHER list|sync|remove
|
CLI: $LAUNCHER list|sync|remove
|
||||||
Config: $STORE_HOME/config.json
|
Config: $STORE_HOME/config.json
|
||||||
Log: $STORE_HOME/store.log
|
Log: $STORE_HOME/store.log
|
||||||
|
|||||||
+4
-4
@@ -2,9 +2,9 @@
|
|||||||
# Remove a WarpEngine store from a Batocera box.
|
# Remove a WarpEngine store from a Batocera box.
|
||||||
#
|
#
|
||||||
# The engine does the removing, not this script: it is the only thing that knows
|
# The engine does the removing, not this script: it is the only thing that knows
|
||||||
# which ROMs, box art, Ports payloads and gamelist entries were the store's — so
|
# which ROMs, box art, Ports payloads, gamelist entries and ES systems were the
|
||||||
# it runs `purge` first, and only then do the launcher, the Ports entry and the
|
# store's — so it runs `purge` first, and only then do the launcher, the Ports
|
||||||
# store home go.
|
# entry and the store home go.
|
||||||
#
|
#
|
||||||
# ./uninstall.sh # the only store installed, or say which one
|
# ./uninstall.sh # the only store installed, or say which one
|
||||||
# STORE_ID=example ./uninstall.sh
|
# STORE_ID=example ./uninstall.sh
|
||||||
@@ -127,5 +127,5 @@ if [ "$DRY_RUN" = "1" ]; then
|
|||||||
say "dry run — nothing was removed."
|
say "dry run — nothing was removed."
|
||||||
else
|
else
|
||||||
say "done. EmulationStation was restarted if it was running; if not, the games are"
|
say "done. EmulationStation was restarted if it was running; if not, the games are"
|
||||||
say "already gone from the gamelists."
|
say "already gone from the gamelists, and so is the store's own menu entry."
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user