From 032c4f0cfb653aaa6a0c1b2db69d3c0dcdc7b4b3 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Sun, 23 Aug 2026 16:26:59 +0200 Subject: [PATCH] Make the menu entry a system EmulationStation keeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a real box (Batocera 43.1) the entry did not show up, and switching themes seemed to decide whether it did. Neither was a theme problem — two EmulationStation behaviours nobody documents: * A system with no games of its own is not merely hidden, it is thrown away: loadSystem logs `System "ttg" has no games! Ignoring it.`. The menu entry was deliberately an empty shelf (`.ttg-none` extension, a `true` command), so it could never have survived. * ES marks a directory it has enumerated with a `/*` entry in its file cache, and it adds that mark *before* reading the contents. From then on, any path under that directory which is not itself cached answers "does not exist" (FileSystemUtil.cpp, getCacheEntry). Systems load in a thread pool, so an entry whose path sat above the systems' paths lost that race for a couple of them, and ES dropped those with `System "ttg-c64" path does not exist !` — a different couple on every start, which is what made it look theme-dependent. Both are fixed by pointing the entry at the `store/` folder: it holds the updater, so it has a game and ES keeps it, and it is a sibling of the platform folders rather than their parent, so there is no cache race to lose. The separate `-store` child system is gone with it, and the "Update " item now sits directly in the entry instead of in a "Store" subfolder — one level less to walk. Also: the migration removed the old Ports script but left its node in the box's ports gamelist, which ES then complained about on every start. It goes now, and only that node. Verified on the box: three consecutive ES restarts with no system dropped, all four gamelists parsed, and the log clean. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 24 ++++++++++--- store.py | 105 ++++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 102 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 5d6dd5a..f018adc 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ The reference store is [`ttg-batocera-store`](https://git.teletypegames.org/stores/ttg-batocera-store). ``` -"" ▸ Store ▸ "Update " +"" ▸ "Update " │ ├─ GET /api/software the whole catalog ├─ keep platforms this box can run c64 → c64, tic80 → tic80 @@ -56,7 +56,7 @@ carousel entry holding a folder per system: main carousel: … ▸ Teletype Games ▸ ┌ Commodore 64 ├ TIC-80 ├ Ports - └ Store → "Update Teletype Games" + └ Update Teletype Games ``` Nothing about emulators is hardcoded here. The engine reads the box's own @@ -69,7 +69,8 @@ ES replaces it with the name of the system being launched, which for us would be 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: +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 @@ -77,6 +78,19 @@ Three details worth knowing, all of them EmulationStation's: single system in it unless a system by that name exists. The declared parent fixes both: it carries a `` (`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 `/*` 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 @@ -173,7 +187,7 @@ menu entry and its `es_systems` file. ## Use -**On the device.** *"\" ▸ Store ▸ "Update \"*. It +**On the device.** *"\" ▸ "Update \"*. 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. @@ -263,7 +277,7 @@ curl -fsSL https://git.teletypegames.org/engines/warpstore/raw/branch/master/uni │ ├── images/.png │ ├── .data// the unpacked programs │ └── gamelist.xml -└── store/ +└── store/ the menu entry itself ├── update.sh the sync, as the menu starts it └── gamelist.xml ``` diff --git a/store.py b/store.py index 344a16c..a890301 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.0.0" +VERSION = "4.1.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 @@ -711,7 +711,9 @@ def indent(elem, level=0): # the store's own menu entry # -------------------------------------------------------------------------- -# The folder that holds the updater. Not a catalog platform, so it can never +# The folder that holds the updater, and — because a menu entry has to be a +# system, and a system with no games of its own is dropped by ES — the folder +# the menu entry itself points at. Not a catalog platform, so it can never # collide with one. STORE_SYSTEM = "store" @@ -741,8 +743,6 @@ def system_label(cfg, system, src): labels = menu(cfg).get("labels") or {} if system in labels: return labels[system] - if system == STORE_SYSTEM: - return "Store" fullname = (src.findtext("fullname") or "").strip() if src is not None else "" return fullname or system @@ -804,24 +804,52 @@ def build_es_systems(cfg, systems, box): parent = es_parent_name(cfg, box) root = ET.Element("systemList") - # The menu entry is declared rather than left to ES to invent, for two - # reasons: it is the only way to give it a `` (a group ES creates - # itself looks for a theme folder named after the group, which no theme - # has), and with `HideUniqueGroups` on — the default — a group with a single - # system in it is dissolved unless a system by that name exists. + # The menu entry is a system of its own rather than a group ES invents by + # itself, for two reasons: it is the only way to give it a `` (a + # group ES creates has no system behind it, so it looks for a theme folder + # named after the group, which no theme has), and with `HideUniqueGroups` + # on — the default — a group with a single system under it is dissolved + # unless a system by that name exists. + # + # Which means it has to be a system ES will keep, and ES keeps a system + # only if it holds at least one game (`loadSystem`: `has no games! Ignoring + # it.`). So the entry is the store folder — the updater in it is that game, + # and it is where the "Update " item comes from. + # + # Its path must also *not* be the directory above the systems'. ES marks a + # directory it has enumerated with a `/*` cache entry, and adds the + # mark **before** reading the entries; 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 !`. `store/` is their + # sibling, so there is no race to lose. + src_store = box.get(source_system(STORE_SYSTEM)) entry = ET.SubElement(root, "system") text_node(entry, "name", parent) text_node(entry, "fullname", menu(cfg).get("name") or cfg["store"]["name"]) - text_node(entry, "path", lay.store_root()) - # ES drops a system that has no extension or no command, so both are here — - # an extension nothing on earth has, and a command that does nothing. This - # entry is a shelf for the systems below, it never holds a game itself. - text_node(entry, "extension", f".{parent}-none") - text_node(entry, "command", "true") + text_node(entry, "path", lay.base(STORE_SYSTEM)) + if src_store is not None: + text_node(entry, "extension", (src_store.findtext("extension") or ".sh").strip()) + text_node(entry, "command", (src_store.findtext("command") or "").replace( + "%SYSTEM%", source_system(STORE_SYSTEM))) + else: + # No `ports` system on this box: the entry still appears, but nothing in + # it can be launched — including the updater. + log(f"warning: no '{source_system(STORE_SYSTEM)}' system on this box — " + "the updater cannot be launched from the menu") + 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") + if src_store is not None: + emulators = src_store.find("emulators") + if emulators is not None: + entry.append(emulators) for system in sorted(systems): + if system == STORE_SYSTEM: + continue src = box.get(source_system(system)) if src is None: log(f"warning: no '{source_system(system)}' system on this box — " @@ -931,13 +959,14 @@ def apply_menu(cfg, systems, dry_run=False, gamelists=True): if not isinstance(lay, SystemLayout): return False - wanted = set(systems) + wanted = set(systems) - {STORE_SYSTEM} updater = bool(menu(cfg).get("updater")) - if updater: - wanted.add(STORE_SYSTEM) - # ES refuses a system whose path does not exist, so the folders go first. - for system in sorted(wanted): - if not dry_run: + # ES refuses a system whose path does not exist, so the folders go first — + # the menu entry's own one (`store/`) included, whether or not the updater + # will put anything in it. + if not dry_run: + os.makedirs(lay.base(STORE_SYSTEM), exist_ok=True) + for system in sorted(wanted): os.makedirs(lay.base(system), exist_ok=True) changed = write_es_systems(cfg, wanted, dry_run=dry_run) @@ -977,6 +1006,36 @@ def ports_entries(cfg): return found +def drop_ports_gamelist_entry(cfg, script, dry_run=False): + """Take the Ports entry's own node out of the box's ports gamelist. + + The node is not under our subfolder — the script sat in the Ports root — so + the gamelist merge leaves it alone, and ES then complains about a game whose + file is gone on every start. + """ + path = os.path.join(roms_root(cfg), "ports", "gamelist.xml") + if not os.path.isfile(path): + return + try: + root = ET.parse(path).getroot() + except ET.ParseError: + return + name = os.path.basename(script) + dropped = [node for node in list(root) + if normalize_path(node.findtext("path")) == name] + if not dropped: + return + if dry_run: + log(f"would remove the '{name}' entry from {path}") + return + for node in dropped: + root.remove(node) + indent(root) + ws.write_atomic(path, b'\n' + + ET.tostring(root, encoding="utf-8")) + log(f"removed the '{name}' entry from {path}") + + def remove_ports_entry(cfg, dry_run=False): for path in ports_entries(cfg): if dry_run: @@ -984,6 +1043,7 @@ def remove_ports_entry(cfg, dry_run=False): else: os.unlink(path) log(f"removed the Ports entry {path}") + drop_ports_gamelist_entry(cfg, path, dry_run=dry_run) def migrate_layout(cfg, dry_run=False): @@ -1389,7 +1449,8 @@ def cmd_purge(cfg, args): merge_gamelist(cfg, STORE_SYSTEM, [], dry_run=args.dry_run, backup=False, lay=store_layout) drop_empty_gamelist(cfg, STORE_SYSTEM, dry_run=args.dry_run, lay=store_layout) - dirs += store_layout.purge_dirs(STORE_SYSTEM) + [store_layout.store_root()] + dirs += store_layout.purge_dirs(STORE_SYSTEM) + dirs.append(store_layout.store_root()) ws.prune_empty_dirs(dirs, root=roms_root(cfg), dry_run=args.dry_run) leftovers = gamelist_backups(cfg, systems, lay=lay)