From ce210edee11361a788e7d4177da8d70b5e5b4471 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Sun, 23 Aug 2026 21:46:08 +0200 Subject: [PATCH] Let a store bring its own carousel logo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 42 ++++++++- config.example.json | 5 +- store.py | 204 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 241 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f018adc..a6a8648 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ The reference store is ├─ GET /api/download?path= .prg / .tic into the store's folder ├─ GET box art ├─ write gamelist.xml title, desc, author, image - └─ write es_systems_.cfg the store's menu entry + ├─ write es_systems_.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. @@ -101,6 +102,39 @@ store called `nes`, say — the entry is called `-store` instead, and the lo says so: an overlay whose `` 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 `.`, 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`. @@ -231,7 +265,8 @@ that knows about them. | `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 and its `es_systems_.cfg` all go, and the box is left as it was. +gamelists, its `es_systems_.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 @@ -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) | | `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.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 `` | | `emulationstation.ports_entry` | `null` → `false` in `system` mode, `true` in `merge` mode | also write a Ports entry that runs the sync | diff --git a/config.example.json b/config.example.json index 536450e..c812def 100644 --- a/config.example.json +++ b/config.example.json @@ -16,7 +16,8 @@ "menu": { "mode": "system", "name": null, - "theme": "ports", + "theme": null, + "logo": null, "updater": true, "labels": {} }, @@ -63,4 +64,4 @@ "timeout": 30, "insecure": false } -} +} \ No newline at end of file diff --git a/store.py b/store.py index a890301..f42fa5c 100755 --- a/store.py +++ b/store.py @@ -50,7 +50,7 @@ except ImportError: "(https://git.teletypegames.org/engines/warpstore)") 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 # overlay goes; the shipped es_systems.cfg is what we read a system's launch @@ -96,9 +96,17 @@ DEFAULT_CONFIG = { "mode": "system", # Label of that menu entry; null means store.name. "name": None, - # Theme folder the entry borrows its logo from. "ports" exists in - # every Batocera theme; a name no theme knows would show up blank. - "theme": "ports", + # Theme folder the entry takes its logo from. Null means: the + # store's own name when it ships a logo (see `logo`), and "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 " entry inside the menu, so the sync can be # started from the same place the games are. "updater": True, @@ -163,6 +171,25 @@ def es_systems_cfg(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): """A Ports entry for the sync — off by default once the store has a menu.""" 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, "command", "true") 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: emulators = src_store.find("emulators") if emulators is not None: @@ -948,6 +975,164 @@ def write_updater(cfg, dry_run=False): 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): """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) if gamelists: 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 @@ -1092,6 +1284,7 @@ def migrate_layout(cfg, dry_run=False): else: os.unlink(updater) remove_es_systems(cfg, dry_run=dry_run) + remove_logos(cfg, state, dry_run=dry_run) for system in sorted(systems): 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 # 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_logos(cfg, state, dry_run=args.dry_run) store_layout = SystemLayout(cfg) updater = os.path.join(store_layout.base(STORE_SYSTEM), "update.sh") if os.path.isfile(updater):