Skip to content

Config Source Parsers

Overview

Tender reads configuration and metadata from multiple local files — ES-DE XML, RetroArch .info files, retrodeck.json, retroarch.cfg, and more. Each source has its own format, its own update cycle, and its own authoritative domain. None of them is redundant with another, even when they describe overlapping concepts like "core name" or "supported extensions". (Two sources were dropped along the way: ES-DE's gamelist.xml until #947 — Tender no longer reads or writes it — and the bundled core_defaults.json snapshot until #1210, which made the live es_systems.xml the sole core/emulator source; see the dropped-source rows below.)

This page catalogs the sources, the rules for parsing them, and the mapping of questions to sources. It is the reference that new parsers should follow and the justification for why existing parsers are shaped the way they are.

Not covered on this page: HTTP API clients (RomM, SteamGridDB, RetroAchievements). Those are handled as network-facing adapter clients, not as config-source parsers. The same layering principles apply but the mechanics (auth, retries, rate limiting) are different enough that they belong in their own documents.

Why multiple sources?

RetroDECK is a stack of independent components — ES-DE as the frontend, RetroArch as the runtime, a glue config layer on top, plus bundled tooling. Each component owns its own metadata in its own format:

  • ES-DE keeps its system definitions in es_systems.xml and display-oriented labels it chose to include — Tender reads a system's default emulator (the first <command> in its declared order) and the full classified command list the picker offers from here, and this is the sole source for that since #1210 (no bundled snapshot). (ES-DE also keeps per-game and per-system core choices in gamelist.xml; Tender neither reads nor writes that file itself, shows the choice the resolver states beside its own, and never launches by it — core deviations are Tender's own state; see Core and Emulator Selection.)
  • RetroArch keeps per-core metadata in .info files shipped alongside each .so — corename, display_name, supported_extensions, firmware_*, database, and more. It decides how saves are organized on disk, which firmware to require, and what extensions each core accepts.
  • RetroDECK glue keeps user-facing configuration in retrodeck.json (paths for ROMs, saves, BIOS, state) and forwards RetroArch's own retroarch.cfg (sort settings, core directories, and everything else RetroArch reads at startup) — which the vendored emu-atlas resolver reads for every save and core question; Tender itself reads none of it.

There is no unified source. The upstream components are developed independently, updated on independent schedules, and have no mechanism to consolidate their metadata. Tender must read each source individually.

Attempting to "normalize" these into a single internal model would fight against upstream — every time ES-DE renames a label or RetroArch ships a new core, the normalization table would drift. The sustainable approach is the opposite: keep each source as its own parser, keep the parsers ignorant of each other, and let services choose the right parser per flow.

Principle: one parser per source, no cross-contamination

Each external source gets exactly one parser. Parsers do not fall back to each other. Services are responsible for picking the right parser for each business flow.

The principle has three parts:

  1. One parser per source. Adding a second reader for the same file creates drift — sooner or later the two readers disagree and the bug lives in the newer one. retrodeck.json has one parser, the vendored resolver's. es_systems.xml has one parser. Every new source follows suit.

  2. No cross-contamination. When a parser cannot answer a question — the file is missing, a field is absent, the value is malformed — the parser returns None (or raises), it does not defer to another parser. Cross-parser fallbacks are how ES-DE's display label ended up being used as a RetroArch save directory name (the underlying bug of #208). Each source owns its own answer or admits it has none.

  3. Services choose the parser per flow. "Which core is active for this ROM?" is an ES-DE question. "What does RetroArch call that core in its own subsystem?" is a RetroArch question. Both questions may appear inside the same service method, and the service is responsible for asking each one at the right parser. Do not invent a parser-of-parsers to hide the choice.

Worked example: ES-DE label vs RetroArch library name

The concrete case that motivated this principle, and the bug in #208:

  • AtlasCatalogueAdapter (asks the resolver, which reads ES-DE) returns a tuple (core_so, label) for "which core is active?". label is ES-DE's display string — e.g. "Snes9x - Current". It is a UI-level name, chosen by the ES-DE/RetroDECK team to disambiguate in the core picker UI.
  • RetroArch, when sort_savefiles_enable = true, writes saves into subdirectories named by the library_name the core itself reports through retro_get_system_info — e.g. "Snes9x" (runloop.c:8822-8888, as the vendored resolver's placement.py cites it). It is set by the core's maintainer and baked into RetroArch's runtime path logic.

These two values are not redundant representations of the same thing. They answer different questions at different layers:

Core ES-DE label RetroArch library_name
Snes9x Snes9x - Current Snes9x
mGBA mGBA mGBA
Beetle PSX HW Beetle PSX HW Beetle PSX HW
SwanStation SwanStation SwanStation
Genesis Plus GX Genesis Plus GX Genesis Plus GX

Four out of five happen to match textually. Snes9x does not — ES-DE added " - Current" to disambiguate from the older Snes9x 2010 variant. The match is incidental, not structural. Future cores, future ES-DE redesigns, and future RetroDECK re-labelings will introduce new mismatches.

Reconciling by whitelist — a table of "ES-DE label → RetroArch library name" mappings — would be a perpetual maintenance burden. Every new core, every label change, every RetroDECK release shifts the table. The correct answer is to not reconcile at all: when you need the save directory name, ask RetroArch; when you need the UI label, ask ES-DE. The lookup is O(1) per source, caching is local, and drift is impossible because neither parser pretends to speak for the other.

Tender no longer puts the save-directory question itself: where a game's save sits, a sort-by-core subfolder included, is the resolver's answer, which reads RetroArch's own configuration and probes the core for its library_name the way RetroArch does (ADR-0041). The example stays because it is the clearest case of the rule.

Question-to-source mapping

Question Authoritative source Why
What's the default emulator for system X? ES-DE es_systems.xml (live, sole source) The default is the first <command> in ES-DE's declared order (libretro or standalone), whatever its state. Tender reads the live file only — never a snapshot — and a choice made in ES-DE never moves it.
Which core has the user pinned for a platform (per-platform deviation)? settings.json platform_cores map A per-platform core is Tender's own user-set intent (ADR-0003 bucket 1), not ES-DE's. See Core and Emulator Selection.
Which core is active for ROM Y (per-game)? Tender's database (roms.emulator_override), layered on the platform and system layers The per-game override is Tender's own state, not ES-DE's. See Core and Emulator Selection.
Which emulator will ROM Y actually launch with (active core)? ActiveCoreResolver: per-game DB → per-platform settings.json → live es_systems.xml default → None One resolver folds Tender's two deviations over the live ES-DE default; every start asks for the command of the same answer's entry (libretro or standalone).
What's the ES-DE display label for a core? ES-DE Label is an ES-DE/RetroDECK UI concern, chosen at the ES-DE config level.
Where does an emulator keep one game's save (sort-by-core subfolder included)? The save answer — the vendored resolver The resolver reads retroarch.cfg and probes the core for its library_name the way RetroArch does, so Tender reads neither for a save path (ADR-0041).
What ROM extensions does a core support? RetroArch .info supported_extensions field libretro-maintainer-authoritative, updated with every core release.
What firmware files does a core need? RetroArch .info firmware_count + firmwareN_* fields libretro-maintainer-authoritative; optional flags included.
What datfile database matches a core's ROMs? RetroArch .info database field libretro-maintainer-authoritative.
Where does RetroDECK put a system's ROMs, its ROM root, BIOS, saves, and its home directory? The vendored resolver, through RetroDECK's handle (adapters/retrodeck_folders.py) A system's ROM folder and the ROM root come from ES-DE's own settings, the rest from retrodeck.json; the resolver reads both, Tender neither.

If a new question appears, the first step is to figure out which source authoritatively owns it. The mapping above grows as new questions are added — treat this table as part of the contract, not a passive catalog.

Parser layout template

New parsers follow a strict domain-plus-adapter split, matching the broader service/adapter architecture documented in Backend Architecture: a pure parse function in domain/, all I/O in an adapters/ class, and a callback Protocol that services depend on. The examples below are written for a RetroArch .info parser; Tender has none of its own — the vendored resolver reads .info files — so they illustrate the shape rather than name code in the tree.

┌──────────────────────────────────────┐
│ services/                            │
│   <SomeService> depends on callback  │
└────────────┬─────────────────────────┘
             │ uses
┌────────────▼─────────────────────────┐
│ services/protocols/ (paths.py)       │
│   <Capability>ProviderFn (Protocol)  │
└────────────▲─────────────────────────┘
             │ implemented by method of
┌────────────┴─────────────────────────┐
│ adapters/<source>.py                 │
│   <Source>Adapter (I/O + caching)    │
└────────────┬─────────────────────────┘
             │ delegates parsing to
┌────────────▼─────────────────────────┐
│ domain/<source>.py                   │
│   pure parse function(s)             │
└──────────────────────────────────────┘

1. Pure parser in backend/domain/<source>.py

A module of pure functions. No I/O, no logging, no filesystem access. Takes text (or already-loaded data) as input and returns a structured representation.

# domain/<source>.py — illustration: a RetroArch .info parser

def parse_core_info(text: str) -> dict[str, str]:
    """Parse a RetroArch .info file's content into a key-value dict.

    Format: INI-like. Lines of the form `key = "value"`, `#` comments,
    blank lines. All values returned as strings — the caller decides
    how to interpret each field.
    """
    ...

Why in domain: parsing is pure logic. It's exhaustively testable with inline text fixtures — no tmp_path, no mocks, no subprocess. This is the cleanest possible unit under test, and it's where format-level edge cases belong (comments, blank lines, unquoted values, escaped characters, line-continuation quirks, etc.).

2. I/O-owning adapter in backend/adapters/<source>.py

A class with a single responsibility: resolve the right file path(s), read bytes, delegate parsing to the domain module, cache the result, handle I/O errors.

# adapters/<source>.py — illustration: a RetroArch .info adapter

class RetroArchCoreInfoAdapter:
    _SYSTEM_CORES_DIR = "/var/lib/flatpak/app/net.retrodeck.retrodeck/current/active/files/..."
    _USER_CORES_SUFFIX = os.path.join(".local", "share", "flatpak", ...)

    def __init__(self, *, user_home: str, logger: logging.Logger) -> None: ...

    def get_core_info(self, core_so: str) -> dict[str, str] | None:
        """Resolve path, read file, parse. None on any failure."""

    def get_corename(self, core_so: str) -> str | None:
        """Convenience accessor for the corename field."""

Why in adapter: anything that touches the filesystem (open, read, stat, glob) is I/O and belongs in the adapter layer per Tender's layering rules. Adapters are allowed to import domain modules — the reverse is not allowed (enforced by import-linter).

3. Protocol(s) in backend/services/protocols/

Services never import concrete adapters. They depend on callable protocols defined in the services/protocols/ package (config-source parsers live in paths.py), and the concrete adapter method is wired up by bootstrap/.

class SupportedExtensionsProviderFn(Protocol):
    def __call__(self, core_so: str) -> frozenset[str] | None: ...

One protocol per capability, not one protocol per adapter. This matches the existing pattern of individual callback protocols (SavesPathProvider, RomsPathProvider, BiosPathProvider, etc.) introduced by the #196 refactor. When the same adapter exposes multiple capabilities (get_corename, get_supported_extensions, get_firmware_requirements, …), each gets its own protocol so services can depend on only what they actually use — and a test double only needs to stub the callables a given test exercises.

4. Wiring in backend/bootstrap/

Adapter instance is created in bootstrap/adapters.py, and bootstrap/services.py threads its method into services that need it. Illustration — RetroArchCoreInfoAdapter is the hypothetical adapter from step 2, and SomeService is a placeholder:

retroarch_core_info = RetroArchCoreInfoAdapter(user_home=user_home, logger=logger)
emulator_catalogue = AtlasCatalogueAdapter(sources=emulator_sources, ...)

some_service = SomeService(
    config=SomeServiceConfig(
        ...,
        get_active_core=emulator_catalogue.get_active_core,                        # ES-DE question
        get_supported_extensions=retroarch_core_info.get_supported_extensions,     # RetroArch question
    ),
)

The service receives callbacks, not an adapter. It has no knowledge of which file or format they come from — which keeps services independent of any single parser and makes them straightforward to test with fake callables.

5. Tests at each layer

  • tests/domain/test_<source>.py — pure-parse tests with inline string fixtures. Edge cases, malformed input, Unicode, whitespace variations, missing fields. No tmp_path, no mocks.
  • tests/adapters/test_<source>.py — adapter tests with tmp_path. Happy path, file missing, OSError, cache hits, candidate-path fallback. This is the only layer that touches real files.
  • tests/services/test_<service>.py — service tests with the callback mocked (MagicMock). Flow tests: what happens on None, what happens on success, what happens when the callback is not injected at all.

Current parsers

Source Format Parser location Layer status What it answers
retrodeck.json JSON _vendor/atlas, through adapters/retrodeck_folders.py — RetroDeckFoldersAdapter ✅ adapter (correct layering) Where RetroDECK keeps its home, BIOS folder and saves root. Tender reads the file not at all: every folder is the resolver's answer, see RetroDECK's folders.
retroarch.cfg INI-ish key = "value" none — read by the vendored resolver ⛔ no longer read by Tender Removed. Tender used to read the save-sorting flags from it to build save paths; the save answer now carries the directory those flags produce (ADR-0041). Every key is the vendored resolver's to read.
es_systems.xml XML _vendor/atlas, through adapters/atlas_catalogue.py ✅ adapter (correct layering) The sole core/emulator source (#1210): every <command> per system, the default emulator, the libretro active core for the BIOS filter, and the <extension> accept-list. The resolver applies ES-DE's custom_systems overlay and states its entries in EFFECTIVE order; the adapter re-sorts on the shipped declared_index so document order decides the default (ADR-0030). Whether an entry starts a game is the resolver's launch command for that game, read in adapters/atlas_launch.py.
es_find_rules.xml XML none — read by the vendored resolver ⛔ no longer read by Tender Removed. Tender used to read it to tell whether a standalone emulator is installed; that is the resolver's answer now, each catalogue entry's availability, read in adapters/atlas_catalogue.py (see Core and Emulator Selection).
gamelist.xml XML none — dropped in #947 ⛔ no longer read or written Removed. Tender used to read the system-level <alternativeEmulator> and write it from the retired System page; it now neither reads nor writes the gamelist. Core deviations are Tender's own state — per-platform in settings.json platform_cores, per-game in the DB (see Core and Emulator Selection).
core_defaults.json (bundled) JSON none — dropped in #1210 ⛔ no longer read (file + generator deleted) Removed. Was a bundled snapshot generated from es_systems.xml (via scripts/generate_core_defaults.py) as an offline default/standalone-curation fallback. Deleted because the live file is a strict superset and RetroDECK is a hard prerequisite, so the fallback could never help a launch (see ADR-0020).
RetroArch .info INI-ish key = "value" none — read by the vendored resolver ⛔ no longer read by Tender Removed. Tender's own parser answered only a core's corename, for per-core save subfolders; the save answer now carries that directory, so the parser and its adapter were deleted with nothing reading them.

es_systems.xml, es_find_rules.xml and RetroArch's .info files have no parser of Tender's own: the vendored emu-atlas resolver reads them, and adapters/atlas_catalogue.py is the seam that turns its answers into Tender's vocabulary — no atlas type reaches a service. A new source that warrants a pure-parse split should follow the parser layout template above (pure parse in domain/, I/O in adapters/).

RetroDECK's folders (retrodeck.json)

Every folder Tender uses in RetroDECK is the resolver's answer, asked through RetroDECK's handle in one reading of the emulator sources (adapters/retrodeck_folders.py, behind the RetroDeckFolders Protocol): a system's ROM folder is rom_location(system).dir, and the ROM root (ES-DE's ROMDirectory), the home, the BIOS folder and the saves root are the handle's roots(). Tender reads no RetroDECK file of its own, builds no folder from a root, and has no fallback folder: where the resolver names none, nothing that depends on it runs and the answer says why. The ROM root, BIOS folder and saves root are whole-source roots, which not every source has; they are used only as RetroDECK's, and only by what still lies in RetroDECK's folders — whether the removed-game cleanup may start, the bound of a BIOS file an older version downloaded there, which places a BIOS link may take, the cleanup's save backups, the start-up removal of leftover .tmp files, and the move code.

A removal of a game's files is bounded by its system's own ROM folder, never by the ROM root: uninstalling, "Uninstall all", the removed-game cleanup's removal of a game's files and its recovery bundle, an archive's extraction, and the adoption's replace ask rom_folders for the systems they touch, and a path must lie strictly inside its system's folder (lib/path_safety.is_inside_folder). A system's folder may be a link to another drive, where its downloads land; resolved, it is the bound those downloads are removed by, where the ROM root would leave them outside it. Nothing falls back to the ROM root (#2285 D1).

A root the resolver only assumed is used for nothing. The roots answer gives each root a provenance: read where the source's settings name it, frontend-default where ES-DE's own default ROM folder applies, and assumed where nothing on the machine names it and the resolver fell back on RetroDECK's usual layout: a folder below the home where the settings name the home, and ~/retrodeck itself where they do not — a retrodeck.json without its paths section or with one key unset, or one that is missing, unreadable or damaged. Tender uses a root that was read or is ES-DE's own default, and none that was only assumed: nothing is downloaded into it, no removal is bounded by it, and the move code moves nothing. Each root is judged by its own provenance alone, also under a marker-* finding, so with a damaged retrodeck.json a game still downloads into, and is removed from, the ROM folder ES-DE's settings name, while a BIOS link is placed in, and removed from, no place under the assumed BIOS folder (#2191 D11). A system's ROM folder lies below the ROM root and goes with it. The rule is the source-agnostic domain/source_roots.py, so every later caller of an EmuDeck folder asks the same one (#2294 D1, D3, D4, D5).

Which root a folder answers by:

Asked for Needs RetroDECK Refused where this root is not usable
a game download (download_folder) detected and switched on the ROM root; also on root-missing for a home that was read
a removal's bound (rom_folders, rom_root) detected, whatever its switch the ROM root
a removal's bound (bios_folder, saves_root) detected, whatever its switch the BIOS folder, the saves root
the move code (move_roots) detected, whatever its switch any of the home, BIOS folder and saves root, as no move and a refused migrate press

The refusal carries a finding, and the panel words it with the sentence that finding's notice shows (withFindingSentence in frontend/src/utils/emulatorSourceWording.ts, applied to every endpoint answer in frontend/src/api/host.ts): the resolver's marker-* or not-set-up finding where one explains why the root was not read, and Tender's own roots-assumed otherwise. The resolver states no finding for an assumed root (emu-atlas#612), so roots-assumed is Tender's code; its data are the settings file (path) and the assumed roots by name (roots), and the source report carries it too, for Main's notice and the source's card. not-set-up refuses everything, since its ROM root has no path; a marker-invalid on the version key alone leaves every root read and refuses nothing. A download also stops on root-missing for a home that was read, because everything it would land in lies below a folder that is not there. The resolver states root-missing, bios-root-missing and saves-root-missing for an assumed root too, so on Main and the source's card all three show only for a root that was read, and the download rule counts root-missing only for a home that was read (#2294 D6). No folder rule reads bios-root-missing, saves-root-missing, roms-root-missing, roms-root-not-absolute or emulator-config-missing; the folders they name are still checked on their own, as before — a missing ROM or BIOS folder, a ROM root the roots answer gives no path for.

A question that fails establishes no folder. The roots answer — home, ROM root, BIOS folder and saves root, with the findings beside them — is asked once, up front, for every folder question; it carries every finding a folder rule reads, so health() and the four single-root questions are not asked. Where detecting the emulator sources raises, or the roots question does, nothing established where the folders are, so every folder question refuses — a removal's bound as well as a download's — the start-up removal of .tmp files removes nothing, the move code sees no move, and the migrate press is refused before anything moves, the pending move left recorded. The one exception is a download while RetroDECK is switched off, which says so as it would anyway: the switch is the user's setting, and holds whatever the resolver answered. The refusal is its own (retrodeck_unanswered) and says RetroDECK's folders could not be established, so Tender downloads into and removes from none of them.; it never reads as RetroDECK not installed — a detection that raised is not one that found nothing, and the reading says which (DetectedSourcesReading.detection_failed, read by this adapter, by adapters/atlas_platforms.py and by the launch check's check_start_source; every other reader of the sources still takes such a reading as one that detected none) — nor as RetroDECK naming no folder, both of which would be statements about an installation nobody heard from. Like a finding's refusal it also keeps the removed-game cleanup from starting (EveryFolderRefused, which both are).

A system's own ROM folder (rom_location(system)) is not a root: it is asked only by the questions that need it, the download's folder and a removal's bound. Its raise refuses those alone, with the same sentence — a game download, using files already on disk and Check Against Server, the adoption's target, and uninstalling, extracting into it and the removed-game cleanup's removal of that system's games — and every other press, and every other system, goes on: "Uninstall all" removes the other systems' games, and the cleanup keeps such a game's record and reports it. From #2244 D13 and #2285 D7.

Without RetroDECK the move code moves nothing either: the migrate press says Moving needs RetroDECK, which is not installed. and the pending move stays recorded (#2285 D6).

A download creates a folder only below a root that exists. A system's ROM folder that is not there yet is created by the download, as ES-DE would create it. A ROM root that does not exist — a drive or SD card that is out — is never created: the folder would land on internal storage, and the drive would hide it once it is back. The saves root follows the same rule through the save answer (Save-file sync).

Every folder is returned symlink-resolved. The folders are handed to the path guards as safe roots — a system's own ROM folder for a game's files, the BIOS folder and saves root for theirs — and the ROM paths those guards are asked about are recorded resolved wherever lib/path_safety.safe_join built them — so a folder left as the resolver spells it makes one directory look like two on any system where /home is a link to /var/home (Bazzite, Silverblue, and the other image-based distributions), and uninstalling a downloaded ROM fails with Path is outside its safe root (#1838).

RetroDECK's home is not a safe root, and it is resolved for a different reason: MigrationService stores it and diffs the stored value against the live one on every startup to decide whether RetroDECK moved. Resolving one side is not enough there, because a marker written before resolving began still carries the other spelling — so the service resolves what it read from kv_config before comparing, through a realpath seam on MigrationFileStore. Two spellings of one directory read as "unchanged"; a move away from a home since deleted still reads as a move, because realpath follows the links in it that still exist and leaves the missing tail as spelled. A marker that survived from before and turns out to name the live home is dropped on that same pass, because otherwise it would stand until the user migrates or dismisses.

The start-up report of missing installs (StartupHealingService, via the ResolvedPathFn seam) resolves both sides before its prefix match: the pending-home markers, and each install's own recorded paths. Neither side is reliably one spelling — a download is recorded through safe_join and so resolved, while a row an older migration relocated carries whatever spelling the home had when it ran — and a match that misses reports an install the move will relocate as missing. Resolving the recorded path is safe there in a way it is not in the deletion guards: the report decides what a log line says and authorizes nothing.

Two user-visible spellings follow from this: the migration-blocked page renders old_path and new_path, both of which are the resolved markers.

Known consumer gaps

One-parser-per-source compliance has two sides, and this page historically only covered one of them:

  1. Parser inventory (covered above). Does each source have exactly one parser? Does that parser live in the right layer? Does it refuse to cross-contaminate?
  2. Consumer compliance (this section). Does every service method that asks a question route it through the right parser? It is possible — and has happened in practice — for every parser to be individually correct while a consumer still builds the wrong answer by calling the wrong parser or by forgetting to call a parser at all.

The gap below is the consumer-side cautionary tale that motivated adding this section.

Case study: SaveService missed the RetroArch corename after #208

The code this describes is gone — the save directory is the resolver's answer since ADR-0041 — and the lesson is kept because it is about consumers, not about that code.

#208 introduced the RetroArchCoreInfoAdapter precisely to fix the "ES-DE label leaks into RetroArch save path" bug described in the Worked example above. That PR correctly updated MigrationService to ask ES-DE which core is active and then ask the RetroArch .info parser for the canonical corename. The parser was written, the protocol was defined, and the migration flow was fixed.

It was not until PR #227 had already merged, during live testing on hardware, that the second shoe dropped: SaveService._get_rom_save_info — which every save-sync flow on every game launch routes through — was still calling domain.save_path.resolve_save_dir(..., sort_by_core=True) without the resolved core_name argument. The domain function's guard if sort_by_core and core_name: silently skipped the core subdir branch, so every save path fell back to {saves_base}/{system}/ instead of {saves_base}/{system}/{corename}/.

The live reproduction: a user restored a GBA save from the game detail page. RetroArch had sort_savefiles_enable = "true", so the migrated save lived at saves/gba/mGBA/Example Quest - Second Journey (USA).srm. The restore wrote to saves/gba/Example Quest - Second Journey (USA).srm — the parent directory. RetroArch at launch read from the core subdir, so the restored save was invisible to the game.

The parsers were fine. The migration consumer was fine. The save-sync consumer was not, because during the #208 fix the author only updated the consumer that the issue explicitly named and did not audit other call sites for the same mistake. That omission is exactly what the "one parser per source" principle is supposed to prevent — but the principle had been documented only as parser-side guidance and never applied as an audit criterion against the existing codebase.

#232 closed the SaveService gap and added this section to make the consumer-compliance dimension explicit.

Consumer checklist for reviewers

When reviewing a PR that touches save-path resolution, core resolution, firmware requirements, or any other capability in the question-to-source mapping table, walk each call site with these questions:

  1. Is the right parser being called at all? The answer to "where does this game's save sit?" is always the save answer. The answer to "which core runs this ROM?" is always ActiveCoreResolver over ES-DE. A consumer that builds a save path of its own, or asks RetroArch which core is active, has skipped a source boundary.
  2. Are all inputs to the parser resolved from their own authoritative parsers? A function that takes a value another parser answers — a core, a system — expects the caller to have asked that parser first. A call that passes None where the value is required is a silent bug wherever the function degrades quietly instead of refusing.
  3. Does the None case fail loudly? When a required parser returns None, the consumer must either (a) skip the operation with a warning, or (b) log a warning and fall back to a documented behavior. Silent fallback with no diagnostic is never acceptable — that is what produced #232.
  4. Does the same call pattern appear elsewhere? If you are fixing one consumer, grep the whole codebase for the same function name and audit every call site.
  5. Is the new consumer covered by regression tests? Every consumer should have unit tests for: the happy path, the None path (parser unresolvable), and the "callback not injected at all" path. The TestGetRomSaveInfo class in tests/services/saves/test_rom_info.py is the reference shape.

Historical examples

Issue Parser state Consumer state Resolution
#208 .info parser did not exist; ES-DE label was the only core name source MigrationService used the ES-DE label as the RetroArch save subdir name Add RetroArchCoreInfoAdapter, resolve corename from .info, wire into migration
#232 .info parser correct (from #208) SaveService._get_rom_save_info still called resolve_save_dir with core_name=None, so sort_by_core was a silent no-op for every save flow Thread get_core_name into SaveService, extract _resolve_retroarch_corename helper mirroring MigrationService, warn+fallback when unresolvable

Both issues are parser-side fine in the sense that the parser itself returned the right answer for the question asked. They are consumer-side bugs: the consumer either asked the wrong parser or forgot to ask the right one. The checklist above exists to catch the second shoe before it drops in production.

Planned / future unlocks

Tender has no .info parser of its own: the vendored resolver reads .info files for every answer Tender puts to it. The files carry more than Tender asks about today, and the capabilities below would each read one of those fields. Each should land in its own issue and its own PR, paced against real need rather than built ahead of it.

Capability .info field(s) Replaces today's Value
Per-core supported extensions supported_extensions ES-DE's per-system <extension> list, read off the installed catalogue Narrows the accept-list to the core that launches rather than the list ES-DE writes for the whole system.
Core switching validation supported_extensions (no check today) Prevent assigning a core to a system whose ROM extensions it can't load.
DAT/database identification database (no use today) Match ROM headers against the right datfile for integrity checks.
Core display names display_name ES-DE label only Secondary source when an ES-DE label is unavailable (rare); never used to override ES-DE.

Per-core firmware requirements used to head that list — the same .info fields (firmware_count, firmware<N>_desc, firmware<N>_path, firmware<N>_opt) against the manual BIOS registry in FirmwareService. It is done, and it did not land as a parser of Tender's own: reading those fields is only half the job, and the other half (resolving each declared path against the live firmware root, following symlinks, keeping "this core declares nothing" apart from "this core's declaration could not be read") is what the vendored resolver already does. It reaches FirmwareService through the FirmwareResolver seam — see Backend Architecture. A capability above is asked of the resolver the same way first; a parser of Tender's own, built by the parser layout template, is the fallback for a field the resolver does not answer.

How to add a new source

When Tender needs to read a new external config/metadata source, the checklist is:

  1. Identify the source and its authoritative domain. Add a row to the Question-to-source mapping table above. If the new source overlaps with an existing one, explicitly decide which parser owns which question — do not merge them.
  2. Write the pure parser in backend/domain/<source>.py. Start with the smallest function that answers the immediate need; grow the API later. Tests first.
  3. Write the adapter in backend/adapters/<source>.py. Path resolution, file read, parsing delegation, caching. Tests with tmp_path.
  4. Add callback protocol(s) in backend/services/protocols/. One protocol per capability, in the existing *Fn Call-protocol style.
  5. Wire in bootstrap/. Instantiate the adapter in adapters.py; thread its method(s) from services.py into the services that need them.
  6. Consume in services. Services depend on the protocol, not on the adapter. Handle the None / failure case at the service layer — no cross-parser fallbacks.
  7. Add a row to Current parsers above and update the decisions log below if there's a non-obvious design choice worth recording.
  8. Update .importlinter if the new adapter needs a contract line to stay independent of services or sibling adapters.

Decisions log

Non-obvious design choices worth preserving:

  • No combined RetroDECK/RetroArch config adapter. Tender reads no RetroDECK file of its own: retrodeck.json and ES-DE's settings are the vendored resolver's, asked through RetroDeckFoldersAdapter; retroarch.cfg and the .info files are the vendored resolver's too. Folding these into one "RetroDECK/RetroArch config" adapter would conflate different owners (the RetroDECK team vs RetroArch and the libretro core maintainers), different change triggers (user configurator edits vs RetroArch's own settings and Flatpak core releases) and different file layouts. This is the applied form of the "one parser per source" principle for the RetroDECK/RetroArch side of the codebase.

  • core_so is the full .so basename including _libretro, and without the .so. AtlasCatalogueAdapter.get_active_core returns (core_so, label) where core_so is e.g. "snes9x_libretro", not "snes9x". The resolver states it with the extension (snes9x_libretro.so, extracted from the <command>); the adapter strips that, because every core identifier Tender holds — the resolved active core, a classified option's core_so — is spelled without it, and comparing the two spellings would silently match nothing. The .info filename is therefore {core_so}.info (e.g. snes9x_libretro.info), not {core_so}_libretro.info. This is a subtle naming quirk documented here so future parser users don't double the suffix.

  • RetroDECK-only path resolution, standalone RetroArch out of scope. The .info adapter only looks under net.retrodeck.retrodeck Flatpak paths — the per-user ~/.local/share/flatpak/... before the system-wide /var/lib/flatpak/..., which is flatpak's own order for an app and therefore the deploy a launch would start. Support for org.libretro.RetroArch Flatpak, native RetroArch installs, and other launchers is deferred — a separate long-term issue tracks broadening Tender's launcher support beyond RetroDECK, and this parser will be extended alongside that work.

  • Candidate paths use the current/active Flatpak symlinks. Both candidate paths for .info files (per-user and system) route through current/active, which is Flatpak's stable symlink to the installed commit. This means the adapter does not need to know the specific Flatpak commit hash, and Flatpak updates do not break path resolution.


Related pages: