Make the menu entry a system EmulationStation keeps

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 `<dir>/*` 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 `<id>-store` child system is gone with it, and the
"Update <store>" 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 16:26:59 +02:00
co-authored by Claude Opus 5
parent 3b72cf81cc
commit 032c4f0cfb
2 changed files with 102 additions and 27 deletions
+19 -5
View File
@@ -31,7 +31,7 @@ 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).
``` ```
"<store name>" ▸ Store ▸ "Update <store name>" "<store name>" ▸ "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
@@ -56,7 +56,7 @@ carousel entry holding a folder per system:
main carousel: … ▸ Teletype Games ▸ ┌ Commodore 64 main carousel: … ▸ Teletype Games ▸ ┌ Commodore 64
├ TIC-80 ├ TIC-80
├ Ports ├ Ports
Store → "Update Teletype Games" └ Update Teletype Games
``` ```
Nothing about emulators is hardcoded here. The engine reads the box's own 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 instead, and a game of ours is launched exactly as the box would launch it, with
the emulator the user configured for that system. 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 - **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 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 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 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. 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 `<dir>/*` 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 - **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. 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 - **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 ## Use
**On the device.** *"\<store name\>" ▸ Store ▸ "Update \<store name\>"*. It **On the device.** *"\<store name\>" ▸ "Update \<store name\>"*. It
downloads anything new, then restarts EmulationStation so the games show up. ES 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 gives a launched script no console, so the output goes to `store.log` in the
store home. store home.
@@ -263,7 +277,7 @@ curl -fsSL https://git.teletypegames.org/engines/warpstore/raw/branch/master/uni
│ ├── images/<name>.png │ ├── images/<name>.png
│ ├── .data/<name>/ the unpacked programs │ ├── .data/<name>/ the unpacked programs
│ └── gamelist.xml │ └── gamelist.xml
└── store/ └── store/ the menu entry itself
├── update.sh the sync, as the menu starts it ├── update.sh the sync, as the menu starts it
└── gamelist.xml └── gamelist.xml
``` ```
+83 -22
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.0.0" VERSION = "4.1.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
@@ -711,7 +711,9 @@ def indent(elem, level=0):
# the store's own menu entry # 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. # collide with one.
STORE_SYSTEM = "store" STORE_SYSTEM = "store"
@@ -741,8 +743,6 @@ def system_label(cfg, system, src):
labels = menu(cfg).get("labels") or {} labels = menu(cfg).get("labels") or {}
if system in labels: if system in labels:
return labels[system] return labels[system]
if system == STORE_SYSTEM:
return "Store"
fullname = (src.findtext("fullname") or "").strip() if src is not None else "" fullname = (src.findtext("fullname") or "").strip() if src is not None else ""
return fullname or system return fullname or system
@@ -804,24 +804,52 @@ def build_es_systems(cfg, systems, box):
parent = es_parent_name(cfg, box) parent = es_parent_name(cfg, box)
root = ET.Element("systemList") root = ET.Element("systemList")
# The menu entry is declared rather than left to ES to invent, for two # The menu entry is a system of its own rather than a group ES invents by
# reasons: it is the only way to give it a `<theme>` (a group ES creates # itself, for two reasons: it is the only way to give it a `<theme>` (a
# itself looks for a theme folder named after the group, which no theme # group ES creates has no system behind it, so it looks for a theme folder
# has), and with `HideUniqueGroups` on — the default — a group with a single # named after the group, which no theme has), and with `HideUniqueGroups`
# system in it is dissolved unless a system by that name exists. # 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 <store>" item comes from.
#
# Its path must also *not* be the directory above the systems'. ES marks a
# directory it has enumerated with a `<dir>/*` 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") entry = ET.SubElement(root, "system")
text_node(entry, "name", parent) text_node(entry, "name", parent)
text_node(entry, "fullname", menu(cfg).get("name") or cfg["store"]["name"]) text_node(entry, "fullname", menu(cfg).get("name") or cfg["store"]["name"])
text_node(entry, "path", lay.store_root()) text_node(entry, "path", lay.base(STORE_SYSTEM))
# ES drops a system that has no extension or no command, so both are here — if src_store is not None:
# an extension nothing on earth has, and a command that does nothing. This text_node(entry, "extension", (src_store.findtext("extension") or ".sh").strip())
# entry is a shelf for the systems below, it never holds a game itself. text_node(entry, "command", (src_store.findtext("command") or "").replace(
text_node(entry, "extension", f".{parent}-none") "%SYSTEM%", source_system(STORE_SYSTEM)))
text_node(entry, "command", "true") 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, "platform", "pc")
text_node(entry, "theme", menu(cfg).get("theme") or "ports") 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): for system in sorted(systems):
if system == STORE_SYSTEM:
continue
src = box.get(source_system(system)) src = box.get(source_system(system))
if src is None: if src is None:
log(f"warning: no '{source_system(system)}' system on this box — " 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): if not isinstance(lay, SystemLayout):
return False return False
wanted = set(systems) wanted = set(systems) - {STORE_SYSTEM}
updater = bool(menu(cfg).get("updater")) updater = bool(menu(cfg).get("updater"))
if updater: # ES refuses a system whose path does not exist, so the folders go first —
wanted.add(STORE_SYSTEM) # the menu entry's own one (`store/`) included, whether or not the updater
# ES refuses a system whose path does not exist, so the folders go first. # will put anything in it.
for system in sorted(wanted): if not dry_run:
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) os.makedirs(lay.base(system), exist_ok=True)
changed = write_es_systems(cfg, wanted, dry_run=dry_run) changed = write_es_systems(cfg, wanted, dry_run=dry_run)
@@ -977,6 +1006,36 @@ def ports_entries(cfg):
return found 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'<?xml version="1.0" encoding="UTF-8"?>\n'
+ ET.tostring(root, encoding="utf-8"))
log(f"removed the '{name}' entry from {path}")
def remove_ports_entry(cfg, dry_run=False): def remove_ports_entry(cfg, dry_run=False):
for path in ports_entries(cfg): for path in ports_entries(cfg):
if dry_run: if dry_run:
@@ -984,6 +1043,7 @@ def remove_ports_entry(cfg, dry_run=False):
else: else:
os.unlink(path) os.unlink(path)
log(f"removed the Ports entry {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): 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, merge_gamelist(cfg, STORE_SYSTEM, [], dry_run=args.dry_run, backup=False,
lay=store_layout) lay=store_layout)
drop_empty_gamelist(cfg, STORE_SYSTEM, dry_run=args.dry_run, 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) ws.prune_empty_dirs(dirs, root=roms_root(cfg), dry_run=args.dry_run)
leftovers = gamelist_backups(cfg, systems, lay=lay) leftovers = gamelist_backups(cfg, systems, lay=lay)