Config Source Parsers¶
Overview¶
The plugin 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 — the plugin
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.xmland display-oriented labels it chose to include — the plugin reads a system's default emulator (the first safely-bakeable<command>) 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 ingamelist.xml, but the plugin no longer reads or writes that file — core deviations are the plugin's own state; see Core and Emulator Selection.) - RetroArch keeps per-core metadata in
.infofiles 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 ownretroarch.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; the plugin itself reads one key of it,input_driver, for the controller check.
There is no unified source. The upstream components are developed independently, updated on independent schedules, and have no mechanism to consolidate their metadata. The plugin 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:
-
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.jsonhas one parser.es_systems.xmlhas one parser. Every new source follows suit. -
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. -
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?".labelis 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 thelibrary_namethe core itself reports throughretro_get_system_info— e.g."Snes9x"(runloop.c:8822-8888, as the vendored resolver'splacement.pycites 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.
The plugin 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 safely-bakeable <command> in ES-DE's document order (libretro or standalone). The plugin reads the live file only — never a snapshot, never the gamelist. |
| Which core has the user pinned for a platform (per-platform deviation)? | settings.json platform_cores map |
A per-platform core is the plugin'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)? | Plugin DB (roms.emulator_override), layered on the platform and system layers |
The per-game override is the plugin'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 the plugin's two deviations over the live ES-DE default; the launched emulator is baked from the same answer (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 the plugin 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 ROMs, saves, BIOS, states, and its home directory? | retrodeck.json |
RetroDECK owns path configuration; it's the file users edit via the RetroDECK configurator. |
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; the
plugin 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 the plugin'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(choose_installation=..., ...)
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 withtmp_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 onNone, 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 | adapters/retrodeck_paths.py — RetroDeckPathsAdapter |
✅ adapter (correct layering) | Where RetroDECK puts saves, ROMs, BIOS, and its home directory. |
retroarch.cfg |
INI-ish key = "value" |
adapters/steam_config.py — SteamConfigAdapter.check_retroarch_input_driver, for input_driver alone |
⚠️ one key, read inline | Save sorting removed. The plugin 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). What remains is the controller check reading and rewriting input_driver; every other 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). The pure classifier domain/emulator_commands.py decides bake-safety. |
es_find_rules.xml |
XML | adapters/es_find_rules.py — EsFindRulesAdapter |
✅ adapter (correct layering) | Where an emulator's binary is, which the catalogue never states. Two questions: whether a standalone emulator is installed in RetroDECK (a bakeable one that is absent is downgraded to needs_setup/not_installed, ADR-0020 §2 — absence-only, so systempath-only / unreadable cases assume installed), and the sandbox component launcher the folder-boot bake execs (ADR-0019). Mtime-cached parse; the on-disk probes run per call. |
gamelist.xml |
XML | none — dropped in #947 | ⛔ no longer read or written | Removed. The plugin 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 the plugin'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 the plugin | Removed. The plugin'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 and RetroArch's .info files have no parser of the plugin's own: the vendored emu-atlas resolver reads
them, and adapters/atlas_catalogue.py is the seam that turns its answers into plugin vocabulary — no atlas type
reaches a service. adapters/es_find_rules.py keeps its XML parsing inline rather than in a separate pure-domain
module; that is acceptable because the adapter owns its I/O. A new source that warrants a pure-parse split should follow
the parser layout template above (pure parse in domain/, I/O in adapters/).
Best-effort fallback and config health (retrodeck.json)¶
RetroDeckPathsAdapter resolves all RetroDECK roots (ROMs, saves, BIOS, home) from the paths block of
retrodeck.json. The path getters (roms_path, saves_path, bios_path, retrodeck_home) are best-effort and
never raise: when the file is missing, unreadable, or malformed, each getter falls back to
<user_home>/retrodeck/<subdir>. That fallback is RetroDECK's own default root, so it is correct for a default install
but wrong for an SD-card install where the user pointed RetroDECK at external storage.
Every root is returned symlink-resolved, whichever of the two sources answered. The content roots (roms_path,
saves_path, bios_path) are handed to the path guards as safe roots, and the ROM paths those guards are asked about
are recorded resolved wherever lib/path_safety.safe_join built them — so a root left as retrodeck.json 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). realpath on a path that is not on disk resolves as
far as it can instead of raising, so the getters stay best-effort.
retrodeck_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 this change 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 the change
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 install prune (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 prunes a record whose files are still on disk. Resolving the
recorded path is safe there in a way it is not in the deletion guards: the prune decides what to keep and authorizes
nothing.
Three user-visible spellings change with this: the root_missing banner's "Expected at:" line reports resolved_home,
and the migration-blocked page renders old_path and new_path, both of which are now the resolved markers.
Silently operating on the wrong root is the failure mode #948
addresses. The fix keeps the getters silent-and-best-effort but pairs them with a loud health signal that the frontend
surfaces as a QAM banner. RetroDeckPathsAdapter.config_health() returns a RetroDeckConfigHealth enum
(backend/lib/retrodeck_health.py — placed in lib/ because the adapter, the RetroDeckPaths Protocol, and main.py
all import it, and import-linter forbids the adapter↔service directions). The four states:
| State | When | Loud? | Rationale |
|---|---|---|---|
ok |
retrodeck.json read successfully and the resolved home exists on disk |
no | Healthy — roots are trustworthy. |
absent |
retrodeck.json not found (FileNotFoundError) |
no | The legitimate fresh-install case — ~/retrodeck is RetroDECK's own default root. absent wins over root_missing even when the ~/retrodeck fallback does not exist yet. |
unreadable |
file exists but cannot be read or parsed (OSError / PermissionError / JSONDecodeError) |
yes | We know RetroDECK is configured but cannot read where its roots point — derived paths are likely wrong. |
root_missing |
retrodeck.json read OK, but the resolved home directory does not exist on disk |
yes | The library volume is gone (e.g. SD card ejected) — syncs and downloads would target a missing/wrong location. |
config_health() reuses the same 30-second TTL cache as the path getters (_load_config()) — no second independent
file read — and tracks the last load outcome so it can distinguish absent from unreadable (a bare None would
conflate them). The root_missing disk probe (os.path.isdir) only runs when the config read OK; it never runs for
absent, so a fresh install stays quiet.
main.py exposes this via the get_retrodeck_status() callable, a discriminated-status union
({status, config_path, resolved_home}) — the Callable response shapes carve-out for >2
outcomes. The backend returns only the discriminant plus the probed paths; the frontend owns the human-readable copy
(frontend/src/utils/retrodeckHealth.ts) and renders the shared WarningCard in the QAM Status panel for the two loud
states. ok and absent render no banner.
Known consumer gaps¶
One-parser-per-source compliance has two sides, and this page historically only covered one of them:
- 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?
- 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:
- 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
ActiveCoreResolverover ES-DE. A consumer that builds a save path of its own, or asks RetroArch which core is active, has skipped a source boundary. - 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
Nonewhere the value is required is a silent bug wherever the function degrades quietly instead of refusing. - Does the
Nonecase fail loudly? When a required parser returnsNone, 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. - 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.
- Is the new consumer covered by regression tests? Every consumer should have unit tests for: the happy path, the
Nonepath (parser unresolvable), and the "callback not injected at all" path. TheTestGetRomSaveInfoclass intests/services/saves/test_rom_info.pyis 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¶
The plugin has no .info parser of its own: the vendored resolver reads .info files for every answer the plugin puts
to it. The files carry more than the plugin 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 the plugin'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 the plugin'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 the plugin needs to read a new external config/metadata source, the checklist is:
- 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.
- 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. - Write the adapter in
backend/adapters/<source>.py. Path resolution, file read, parsing delegation, caching. Tests withtmp_path. - Add callback protocol(s) in
backend/services/protocols/. One protocol per capability, in the existing*FnCall-protocol style. - Wire in
bootstrap/. Instantiate the adapter inadapters.py; thread its method(s) fromservices.pyinto the services that need them. - 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. - Add a row to Current parsers above and update the decisions log below if there's a non-obvious design choice worth recording.
- Update
.importlinterif 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.
RetroDeckPathsAdapterreadsretrodeck.jsonand nothing else. The oneretroarch.cfgkey the plugin reads itself,input_driver, is read bySteamConfigAdapterbeside the controller check it serves; the rest ofretroarch.cfg, and the.infofiles, are the vendored resolver's. 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_sois the full.sobasename including_libretro, and without the.so.AtlasCatalogueAdapter.get_active_corereturns(core_so, label)wherecore_sois 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 the plugin holds — the resolved active core, theplatform_coresoverride, a classified option'score_so— is spelled without it, and comparing the two spellings would silently match nothing. The.infofilename 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
.infoadapter only looks undernet.retrodeck.retrodeckFlatpak 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 (adapters/flatpak_install.py; the emulator catalogue and the find rules resolve through the same order, so all three describe one RetroDECK). Support fororg.libretro.RetroArchFlatpak, native RetroArch installs, and other launchers is deferred — a separate long-term issue tracks broadening the plugin's launcher support beyond RetroDECK, and this parser will be extended alongside that work. -
Candidate paths use the
current/activeFlatpak symlinks. Both candidate paths for.infofiles (per-user and system) route throughcurrent/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:
- Backend Architecture — service/adapter architecture, dependency diagram, boundary enforcement
- Save File Sync Architecture — save sync details, conflict detection, following a moved save directory
- RetroDECK Path Migration — user-facing guide for moving a RetroDECK install between storage locations