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>
This commit is contained in:
2026-08-23 21:46:08 +02:00
co-authored by Claude Opus 5
parent 032c4f0cfb
commit ce210edee1
3 changed files with 241 additions and 10 deletions
+39 -3
View File
@@ -39,7 +39,8 @@ The reference store is
├─ GET /api/download?path=<asset> .prg / .tic into the store's folder ├─ GET /api/download?path=<asset> .prg / .tic into the store's folder
├─ GET <software.imageUrl> box art ├─ GET <software.imageUrl> box art
├─ write gamelist.xml title, desc, author, image ├─ write gamelist.xml title, desc, author, image
─ write es_systems_<id>.cfg the store's menu entry ─ 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. Python 3 standard library only — Batocera ships python3 and no pip.
@@ -101,6 +102,39 @@ store called `nes`, say — the entry is called `<id>-store` instead, and the lo
says so: an overlay whose `<name>` matches an existing system would *modify that says so: an overlay whose `<name>` matches an existing system would *modify that
system* rather than add one. 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, 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`. merged into the box's gamelists — set `emulationstation.menu.mode` to `merge`.
@@ -231,7 +265,8 @@ that knows about them.
| `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 |
In the store's own layout an uninstall is total: the store's ROM folder, its 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. 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: 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 - **ROMs you put in the store's subfolder yourself.** Directories are removed
@@ -313,7 +348,8 @@ stays a favourite across a sync.
| `paths.subfolder` | `warp` | the store's own folder inside `roms_root` (in `merge` mode: its subfolder inside each system) | | `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.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.name` | `null``store.name` | label of the menu entry |
| `emulationstation.menu.theme` | `ports` | theme folder the entry borrows its logo from | | `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.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.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.ports_entry` | `null``false` in `system` mode, `true` in `merge` mode | also write a Ports entry that runs the sync |
+2 -1
View File
@@ -16,7 +16,8 @@
"menu": { "menu": {
"mode": "system", "mode": "system",
"name": null, "name": null,
"theme": "ports", "theme": null,
"logo": null,
"updater": true, "updater": true,
"labels": {} "labels": {}
}, },
+199 -5
View File
@@ -50,7 +50,7 @@ except ImportError:
"(https://git.teletypegames.org/engines/warpstore)") "(https://git.teletypegames.org/engines/warpstore)")
from warpstore import debug, die, log from warpstore import debug, die, log
VERSION = "4.1.0" VERSION = "4.2.0"
# EmulationStation's own files. The user config dir is where our es_systems # EmulationStation's own files. The user config dir is where our es_systems
# overlay goes; the shipped es_systems.cfg is what we read a system's launch # overlay goes; the shipped es_systems.cfg is what we read a system's launch
@@ -96,9 +96,17 @@ DEFAULT_CONFIG = {
"mode": "system", "mode": "system",
# Label of that menu entry; null means store.name. # Label of that menu entry; null means store.name.
"name": None, "name": None,
# Theme folder the entry borrows its logo from. "ports" exists in # Theme folder the entry takes its logo from. Null means: the
# every Batocera theme; a name no theme knows would show up blank. # store's own name when it ships a logo (see `logo`), and "ports"
"theme": "ports", # otherwise — a folder every Batocera theme has, so the entry has
# *some* icon rather than none.
"theme": None,
# The store's own carousel logo: a path or URL, or a list of them
# (one per format — a theme may look for .png, another for .svg).
# EmulationStation has no place of its own for this, so the file is
# installed into the logo folder of every theme on the box; a theme
# we cannot reach shows the store's name as text instead.
"logo": None,
# An "Update <store>" entry inside the menu, so the sync can be # An "Update <store>" entry inside the menu, so the sync can be
# started from the same place the games are. # started from the same place the games are.
"updater": True, "updater": True,
@@ -163,6 +171,25 @@ def es_systems_cfg(cfg):
return os.environ.get("BATOCERA_ES_SYSTEMS_CFG") or ES_SYSTEMS_CFG return os.environ.get("BATOCERA_ES_SYSTEMS_CFG") or ES_SYSTEMS_CFG
def menu_theme(cfg):
"""The theme folder the menu entry uses — and the name of its logo file.
With a logo of our own it has to be a name of ours, or installing it would
overwrite the theme's own artwork for whatever folder we borrowed.
"""
explicit = menu(cfg).get("theme")
if explicit:
return explicit
return cfg["store"]["id"] if logo_sources(cfg) else "ports"
def logo_sources(cfg):
value = menu(cfg).get("logo")
if not value:
return []
return [value] if isinstance(value, str) else list(value)
def wants_ports_entry(cfg): def wants_ports_entry(cfg):
"""A Ports entry for the sync — off by default once the store has a menu.""" """A Ports entry for the sync — off by default once the store has a menu."""
want = cfg["emulationstation"].get("ports_entry") want = cfg["emulationstation"].get("ports_entry")
@@ -841,7 +868,7 @@ def build_es_systems(cfg, systems, box):
text_node(entry, "extension", ".sh") text_node(entry, "extension", ".sh")
text_node(entry, "command", "true") text_node(entry, "command", "true")
text_node(entry, "platform", "pc") text_node(entry, "platform", "pc")
text_node(entry, "theme", menu(cfg).get("theme") or "ports") text_node(entry, "theme", menu_theme(cfg))
if src_store is not None: if src_store is not None:
emulators = src_store.find("emulators") emulators = src_store.find("emulators")
if emulators is not None: if emulators is not None:
@@ -948,6 +975,164 @@ def write_updater(cfg, dry_run=False):
log(f"updater: {path}") log(f"updater: {path}")
# --------------------------------------------------------------------------
# the store's logo in the themes
# --------------------------------------------------------------------------
# A theme keeps its system logos in a folder of its own choosing — `art/logos`,
# `_inc/systems/logos`, `_art/Colorlogos`, whatever the author picked. Rather
# than knowing them all, we look for the folder that holds the logos of systems
# every theme has: the one with the most of these in it is the logo folder.
LOGO_PROBES = frozenset((
"ports", "snes", "nes", "megadrive", "psx", "gba", "n64", "c64", "atari2600",
"dreamcast", "amiga500", "gb", "gbc", "mame", "pcengine", "saturn", "segacd",
"tic80", "wii", "3do", "gamegear", "mastersystem",
))
LOGO_EXTS = (".png", ".svg", ".jpg", ".webp")
def theme_dirs(cfg):
"""Every theme installed on the box, from the paths ES looks in."""
roots = [os.path.join(roms_root(cfg), "..", "themes"),
os.path.join(es_config_dir(cfg), "themes"),
"/usr/share/emulationstation/themes"]
if os.environ.get("BATOCERA_THEMES_DIRS"):
roots = os.environ["BATOCERA_THEMES_DIRS"].split(os.pathsep)
found = []
for root in roots:
root = os.path.normpath(root)
if not os.path.isdir(root):
continue
for name in sorted(os.listdir(root)):
path = os.path.join(root, name)
if not name.startswith(".") and os.path.isdir(path):
found.append(path)
return found
def theme_logo_dir(root):
"""The folder this theme keeps its system logos in, or None."""
best, best_score = None, 0
for base, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if not d.startswith(".")]
names = {os.path.splitext(f)[0].lower() for f in files
if os.path.splitext(f)[1].lower() in LOGO_EXTS}
score = len(names & LOGO_PROBES)
if score > best_score or (score and score == best_score and len(base) < len(best)):
best, best_score = base, score
# A handful of known systems is proof enough; fewer means we found a folder
# of something else, and a wrong guess would litter the user's theme.
return best if best_score >= 4 else None
def cached_logos(cfg, dry_run=False):
"""The logo files, in the store home. `{extension: path}`.
`menu.logo` may be a URL or a path; either way it is copied in once and used
from there, so a theme installed later does not need the network.
"""
out = {}
for source in logo_sources(cfg):
ext = os.path.splitext(source)[1].lower()
if ext not in LOGO_EXTS:
log(f"warning: {source} is not a logo we can install ({', '.join(LOGO_EXTS)})")
continue
local = os.path.join(ws.HOME, f"logo{ext}")
if not os.path.isfile(local):
if dry_run:
log(f"would fetch the menu logo {source}")
continue
try:
if source.startswith(("http://", "https://")):
ws.http_download(cfg, source, local)
else:
path = source if os.path.isabs(source) else os.path.join(ws.HOME, source)
shutil.copyfile(path, local)
log(f"menu logo: {local}")
except (urllib.error.URLError, OSError) as exc:
log(f"warning: cannot read the menu logo {source}: {exc}")
continue
out[ext] = local
return out
def install_logos(cfg, state, dry_run=False):
"""Put the store's logo where each theme looks for a system logo.
EmulationStation resolves a system's carousel logo through the theme
(`SystemData::getProperty("image")` asks the theme for `system/logo`), and it
has no user-level override — so the only way for a custom system to have its
own artwork is to install the file into the themes. Which is why every path
written is recorded in `state.json`: an uninstall takes them all back out,
and a file we did not write is never touched.
"""
sources = cached_logos(cfg, dry_run=dry_run)
if not sources:
return
name = menu_theme(cfg)
known = state.setdefault("theme_logo_dirs", {})
written = state.setdefault("logos", {})
volatile = []
for root in theme_dirs(cfg):
target_dir = known.get(root)
if not target_dir or not os.path.isdir(target_dir):
target_dir = theme_logo_dir(root)
if target_dir and not dry_run:
known[root] = target_dir
if not target_dir:
debug(f"{root}: no system-logo folder found — this theme will show "
"the store's name as text")
continue
for ext, source in sources.items():
target = os.path.join(target_dir, name + ext)
if os.path.exists(target) and target not in written:
log(f"warning: {target} already exists and is not ours — left alone")
continue
size = os.path.getsize(source)
if os.path.isfile(target) and os.path.getsize(target) == size:
continue
if dry_run:
log(f"would install the logo into {target}")
continue
try:
shutil.copyfile(source, target)
# The rest of a theme is world-readable; ours should be too.
os.chmod(target, 0o644)
except OSError as exc:
log(f"warning: cannot write {target}: {exc}")
continue
written[target] = size
log(f"logo installed: {target}")
# Batocera's root is a RAM overlay: a theme that ships with the
# system image takes the file, but loses it on the next boot.
if target.startswith("/usr/"):
volatile.append(target)
if volatile:
log("note: these are on the read-only system image, so the copy lives in "
"Batocera's RAM overlay and is gone after a reboot (the next sync puts "
"it back; `batocera-save-overlay` makes it permanent):")
for path in volatile:
log(f" {path}")
def remove_logos(cfg, state, dry_run=False):
written = state.get("logos") or {}
for target in sorted(written):
if not os.path.isfile(target):
continue
if dry_run:
log(f"would remove the logo {target}")
else:
os.unlink(target)
log(f"removed the logo {target}")
if not dry_run:
state.pop("logos", None)
state.pop("theme_logo_dirs", None)
def apply_menu(cfg, systems, dry_run=False, gamelists=True): def apply_menu(cfg, systems, dry_run=False, gamelists=True):
"""Write everything the store's menu entry is made of. """Write everything the store's menu entry is made of.
@@ -974,6 +1159,13 @@ def apply_menu(cfg, systems, dry_run=False, gamelists=True):
write_updater(cfg, dry_run=dry_run) write_updater(cfg, dry_run=dry_run)
if gamelists: if gamelists:
merge_gamelist(cfg, STORE_SYSTEM, updater_records(cfg), dry_run=dry_run) merge_gamelist(cfg, STORE_SYSTEM, updater_records(cfg), dry_run=dry_run)
state = ws.load_state()
before = dict(state.get("logos") or {})
install_logos(cfg, state, dry_run=dry_run)
if not dry_run and (state.get("logos") or {}) != before:
ws.save_state(state)
changed = True
return changed return changed
@@ -1092,6 +1284,7 @@ def migrate_layout(cfg, dry_run=False):
else: else:
os.unlink(updater) os.unlink(updater)
remove_es_systems(cfg, dry_run=dry_run) remove_es_systems(cfg, dry_run=dry_run)
remove_logos(cfg, state, dry_run=dry_run)
for system in sorted(systems): for system in sorted(systems):
merge_gamelist(cfg, system, [], dry_run=dry_run, backup=False, lay=old) merge_gamelist(cfg, system, [], dry_run=dry_run, backup=False, lay=old)
@@ -1430,6 +1623,7 @@ def cmd_purge(cfg, args):
# points at them. Both are removed whichever layout is recorded — a leftover # points at them. Both are removed whichever layout is recorded — a leftover
# es_systems file would leave the box with a menu entry and nothing in it. # es_systems file would leave the box with a menu entry and nothing in it.
remove_es_systems(cfg, dry_run=args.dry_run) remove_es_systems(cfg, dry_run=args.dry_run)
remove_logos(cfg, state, dry_run=args.dry_run)
store_layout = SystemLayout(cfg) store_layout = SystemLayout(cfg)
updater = os.path.join(store_layout.base(STORE_SYSTEM), "update.sh") updater = os.path.join(store_layout.base(STORE_SYSTEM), "update.sh")
if os.path.isfile(updater): if os.path.isfile(updater):