Skip to content

Core and Emulator Selection

Overview

A RomM game launches through RetroDECK on some emulator — most often a RetroArch libretro core, but for a few platforms (PS2, PS3, …) a standalone emulator (PCSX2, RPCS3) that ES-DE lists as the working default. Most games use their platform's default emulator, but the user can pin a different core for a single game (a per-game emulator override) or for a whole platform (a per-platform emulator override). This page documents how the plugin decides which emulator a game uses, where that decision is stored, and how it is applied at launch.

The central rule: the read-path core equals the launched core. Whatever core the plugin reports for a game — in the BIOS-requirement filter, the save-directory name, the save-sync core tag, the core-change warning, the game-detail badge — is the exact core that game will launch on. A single resolver guarantees that, and the launch command is baked from the same resolved emulator. The plugin owns emulator selection end to end: it reads RetroDECK/ES-DE configuration for the default emulator, but its own launches never depend on ES-DE's gamelist.xml — it neither reads nor writes that file. The live es_systems.xml is the sole source for the system-layer default and the picker's emulator list; there is no bundled snapshot (the curated core_defaults.json and its generator were deleted in #1210). What reads that file is the vendored emu-atlas resolver, through adapters/atlas_catalogue.py — the plugin's own parser is gone (#1840). See ADR-0011 (the per-game DB override + -e), ADR-0012 (per-platform core in settings.json, always -e, gamelist dropped), ADR-0020 (live es_systems.xml as the only source; the default is the first safely-bakeable command), and ADR-0030 (the resolver is what reads it; ES-DE's selections are ignored and its overlays honoured) for the decision records.

Two emulator kinds: libretro core and standalone

The resolved emulator is a pure value object — domain.shortcut_data.EmulatorInvocation — that carries exactly one of two payloads:

  • kind == "libretro" — a RetroArch core, identified by its bare .so name (core_so). Rendered as the -e "%EMULATOR_RETROARCH% -L <coresdir>/<so>.so %ROM%" form. This is the overwhelming-majority path.
  • kind == "standalone" — a standalone emulator, identified by its full ES-DE <command> text (already ending in %ROM%, e.g. %EMULATOR_RPCS3% --no-gui %ROM%). Baked verbatim into -e. RetroDECK resolves %EMULATOR_*% and substitutes %ROM% at launch, the same as the libretro form. This is the standalone-emulator seam (#129): a system whose working ES-DE default is a standalone emulator (PS2 → PCSX2, PS3 → RPCS3, GameCube/Wii → Dolphin, PSP → PPSSPP, …) launches on that emulator instead of a deprecated/absent libretro core.

Both kinds are first-class throughout: the system default may be either (whichever ES-DE lists first that the plugin can bake), and the per-game / per-platform picker lists both — so a pin may name a standalone emulator OR a libretro core (#1210). A pin is stored as the emulator LABEL and resolved through the same classified option list at use time.

A standalone emulator has no libretro .so, so the read-path projection reports core_so = None and every .so-space consumer degrades exactly as it does for an unconfigured platform (see the single read seam).

The two override scopes

The plugin owns two deviations from the RetroDECK default core. Each stores only the deviation as a core LABEL; absence means "follow the default."

Scope Stored where Applies to Written by
Per-game Plugin DB — roms.emulator_override (nullable LABEL) one ROM (by rom_id) the plugin (pin/clear)
Per-platform settings.json — platform_cores map ({slug: label}) every ROM on a platform the plugin (set_system_core)

Both overrides are the plugin's own state and live in the plugin's own stores. Neither is written into ES-DE's gamelist.xml — the plugin never writes that file. It still reads the RetroDECK/ES-DE configuration it does not own (the live es_systems.xml — the system default emulator and the full classified command list the picker offers), but the per-game and per-platform deviations are layered on top of that read by the plugin itself.

Storage: the per-game override is a LABEL on the Rom aggregate

roms.emulator_override is a nullable TEXT column added by migration 002_add_emulator_override.sql. It holds the core LABEL the user picked (e.g. "Beetle PSX HW"), exactly as ES-DE displays it — never a resolved .so filename.

  • NULL = no override → the game follows the RetroDECK/ES-DE default.
  • It anchors on roms, not rom_installs, so the choice survives uninstall/reinstall (per ADR-0007).
  • Mutations go through the verb-named aggregate methods Rom.pin_emulator_override(label) (rejects a blank label) and Rom.clear_emulator_override(). Only pin/clear ever write the column; it is excluded from the sync UPSERT SET clause, so a re-sync never wipes a user's pin.

The plugin stores the deviation (the LABEL, or NULL), not a resolved core. The default and system layers are owned by RetroDECK/ES-DE and change externally — a RetroDECK update can ship a new default emulator — so a stored resolved value would go stale. Storing only the deviation keeps the plugin authoritative over exactly the slice it owns and re-resolves the rest live. The LABEL is turned into an EmulatorInvocation (a libretro .so or a standalone command) through the live-es_systems.xml classified option list at use time (domain.emulator_commands.label_to_invocation) — so a per-game pin may name a standalone emulator, not only a libretro core (#1210).

Storage: the per-platform core is a LABEL in settings.json

The per-platform core lives in a platform_cores map in settings.json — {platform_slug: core_label} — added at settings schema version 7 (a setdefault("platform_cores", {}) migration; adapters/persistence.py + domain/state_migrations.py). It holds the same kind of value as the per-game pin: the core LABEL, never a resolved .so. An absent key means "no per-platform deviation — follow the es_systems default for this platform."

It is an ADR-0003 bucket-1 value: a flat, user-set, relationship-free intent toggle. So it lives in settings.json, not SQLite, and there is no Platform aggregate — consistent with the platform_slug-is-denormalized stance. The map starts empty: there is no seed and no import from any previously-set ES-DE gamelist core (see No migration). The plugin reads it through the PlatformCoreReader Protocol (PlatformCoreReaderAdapter in adapters/persistence.py), which holds the live settings dict so a fan-out after a write resolves the freshly-written value rather than a stale snapshot.

The single read seam: ActiveCoreResolver

ActiveCoreResolver (backend/services/active_core_resolver.py) is the one place that answers "which emulator will this ROM actually launch with?" It exposes two methods over the same three-layer resolution:

  • active_emulator_for_rom(rom_id) -> EmulatorInvocation | None — the launch-bake seam. Returns the full resolved emulator (libretro core OR standalone), or None when the platform has no resolvable emulator at all.
  • active_core_for_rom(rom_id) -> (core_so, label) — the read-path projection of the above, kept for the .so-space consumers. A libretro emulator yields (core_so, label); a standalone emulator yields (None, label); an unresolvable platform yields (None, None). Consumers already degrade on a None core, so a standalone launch never breaks them.

The precedence is the invariant:

per-game DB emulator_override (top) → per-platform settings.json platform_cores → live es_systems default → None (plain launch).

active_emulator_for_rom(rom_id):
  rom = read roms row (platform_slug + emulator_override)  ── one UoW read
  system = resolve_system(rom.platform_slug)               ── platform→system (ADR-0010)
  options = get_emulator_options(system)["options"]        ── every es_systems <command>, classified
  if rom.emulator_override is not None:                    ── layer 1: per-game pin (libretro OR standalone)
      inv = label_to_invocation(options, override)
      if inv is not None:
          return inv
      # stale / un-bakeable per-game label: warn, fall through
  platform_label = get_platform_core(rom.platform_slug)    ── layer 2: per-platform settings.json (libretro OR standalone)
  if platform_label is not None:
      inv = label_to_invocation(options, platform_label)
      if inv is not None:
          return inv
      # stale / un-bakeable per-platform label: warn, fall through
  return get_default_emulator(system)                      ── layer 3: live es_systems default (may be standalone), else None

active_core_for_rom(rom_id):                               ── the .so-space projection
  e = active_emulator_for_rom(rom_id)
  return (None, None) if e is None else (e.core_so, e.label)

Layers 1 and 2 resolve their LABEL through domain.emulator_commands.label_to_invocation, which finds the option carrying that label and renders it into an EmulatorInvocation only if it is bakeable — so a pin may name a standalone emulator or a libretro core, and a label that is unknown, un-bakeable, or needs_setup reads as "this pin no longer resolves" and degrades to the next layer with a WARNING (never a bogus None.so). The system-layer fallback is AtlasCatalogueAdapter.get_default_emulator(system) (adapters/atlas_catalogue.py): the first safely-bakeable command in the live es_systems.xml document order (see Standalone-emulator selection), which may itself be standalone (PCSX2, RPCS3, Dolphin, …) or libretro. Only the live file decides it — no bundled snapshot — and no gamelist selection ever moves it: the resolver reads the gamelist and states which entry a per-game <altemulator> or a system-level <alternativeEmulator> promotes, and the adapter discards that by sorting on the shipped position, so the gamelist stays off every plugin launch path. When nothing is bakeable, or the catalogue cannot be read, it returns None and the caller bakes the plain RetroDECK launch.

Standalone-emulator selection: first safely-bakeable

Selection is data-derived from the live es_systems.xml alone — there is no curated snapshot. The pure kernel domain/emulator_commands.py classifies each of a system's ES-DE <command> entries into an EmulatorOption (label, kind, core_so, command, status, reason), and select_default_option returns the first bakeable command in document order. ES-DE already lists a system's emulators in preference order, so ES-DE's own preference picks the default — no plugin curation, no per-system table.

Document order is recovered, not taken. The resolver states its entries in effective order, where a gamelist promotion may put a later entry first, and carries each entry's shipped position as declared_index. The adapter sorts on that position before classifying, so the list handed to select_default_option, label_to_invocation, resolve_platform_label and options_to_payload is already in declared order and every one of them keeps its rule unchanged. The indices are ascending but need not start at 0 or be dense — ES-DE's own walk lets an empty-text <command> hold a position without yielding an entry, and drops a duplicate label entirely — so neither entries[0] nor "index 0 exists" is assumed. An entry with no declared position sorts last; that is the resolver's derived enumeration for a catalogue-less arrangement, and such an entry carries an empty command, which rule 3 below reads as no_rom_target.

A command is bakeable only when it is a real emulator invocation the plugin can carry verbatim into a Steam shortcut's -e. classify_command applies these rules in order, first match wins:

  1. contains %INJECT% → needs_setup ("inject") — needs ES-DE to generate a sidecar first (Vita3K, Xemu);
  2. contains %ENABLESHORTCUTS% or %EMULATOR_OS-SHELL% → unbakeable ("shortcut_script");
  3. does not end in %ROM% → unbakeable ("no_rom_target") — trailing args after %ROM% break the bake;
  4. contains " or \; → unbakeable ("quoting");
  5. any placeholder outside the whitelist (%EMULATOR_*%, %ROM%, %CORE_RETROARCH%, %GAMEDIR%, %GAMEDIRRAW%, %ROMPATH%, %BASENAME%, %FILENAME%, %ROMRAW%, %STARTDIR%) → unbakeable ("unknown_placeholder");
  6. contains %STARTDIR% → unbakeable ("startdir") — RetroDECK's run_game.sh parses-but-drops it, so a baked command would launch from the wrong directory (a v1 limitation);
  7. otherwise bakeable.

This is why a system whose first command is a fragile shortcut form (%ENABLESHORTCUTS% %EMULATOR_OS-SHELL% %ROM%) skips it and bakes the next command (the direct --no-gui launch): the shortcut form is unbakeable, so select_default_option walks past it. A leading env VAR=val … prefix (the GameCube/Wii Dolphin form) is accepted as a standalone invocation. The kind is libretro when the command matches the strict RetroArch shape (%EMULATOR_RETROARCH% -L %CORE_RETROARCH%/<core>_libretro.so %ROM%), else standalone.

Existence probe (a bakeable standalone whose emulator is not installed → needs_setup not_installed). RetroDECK lists more standalone emulators in es_systems.xml than it bundles, so a system's bakeable default could name an emulator that is not on disk (Ryubing on switch, any un-installed external component) — baking it produces a shortcut that dies in ~0.4 s. To prevent that regression the catalogue adapter asks an injected probe backed by es_find_rules.xml (adapters/es_find_rules.py — the resolver states the emulator a command names, never where its binary lives): it maps a standalone command's %EMULATOR_<NAME>% token to the find-rule entry, checks whether any of that entry's staticpath locations exist on disk (mapping the sandbox /app and /var/{data,config} prefixes to their host paths, glob-aware), and hands the verdict to the pure downgrade_if_not_installed(option, installed) rule — which turns a bakeable standalone whose emulator is absent into needs_setup with reason not_installed. It then drops out of select_default_option (the system plain-launches, as it did before #1210) and shows disabled in the picker. The probe is absence-only — it downgrades only on positive evidence that a RetroDECK component (retrodeck/components/… or retrodeck/external_components/…) is missing; a systempath-only emulator (binary on RetroDECK's sandbox PATH, not visible from outside), an emulator with no find rule, and the whole es_find_rules.xml-unreadable case are all assumed installed, so it never falsely downgrades. Libretro is always installed (RetroArch ships with RetroDECK), so libretro options are never downgraded. See ADR-0020 §2.

get_emulator_options(system) returns {"available": bool, "options": [EmulatorOption, ...]}. available is False whenever nothing could be established: no installation was detected at all; the resolver raised; the catalogue answer carries one of the four emulator-catalogue-* refusals — the arrangement ships no catalogue, the resolver has not located one, the one it has could not be read, or only part of it is readable; or it carries catalogue-invalid, a file ES-DE refuses its whole load on (usually a typo in the user's own custom_systems overlay). The picker surfaces all of them as "Emulator list unavailable" rather than an empty list it cannot distinguish from a system the frontend knows no emulator for; the launch degrades to plain. An empty list carrying none of those codes is that real "knows none", and emulator-catalogue-exclusive is not a refusal at all: a custom es_systems.xml declaring itself the whole catalogue gives a complete answer, merely a small one. The test is the codes and never an empty caveat list — a broken installation states health findings on every answer it gives. options_to_payload projects the list to the frontend picker shape ({label, kind, core_so, is_default, bakeable, reason}): bakeable entries are clickable, the default is marked, and needs_setup / unbakeable entries are disabled with their reason. See ADR-0020 — including the 27 default flips this selection rule produces relative to the old first-libretro default.

Adding a new standalone system needs no plugin change — the moment a RetroDECK update lists its emulator in es_systems.xml in preference order, the plugin's default follows.

Every per-game core read consumer draws from this one seam, so the launch core cannot diverge from any derived value:

Consumer What it uses the core for
FirmwareService / game-detail BIOS check which BIOS files the active core requires (optional vs req)
RomInfoService (saves) → save answer which emulator is asked where the save lives
SyncEngine (saves) core tag the per-core save-sync identity
StatusService.check_core_change detect a core change since the last save sync
GameDetailService → CPU badge / Active Core the core shown on the game detail page

A pinned per-game or per-platform label that no longer resolves (the core was removed by a RetroDECK update) is never fatal: the resolver logs a WARNING and degrades to the next layer (the per-platform core, then the es_systems default). No consumer ever sees a bogus .so.

Application: baking -e into launch_options

Per ADR-0009, the launcher is a pure exec "$@" wrapper and the full launch command lives in the Steam shortcut's launch_options. The pure seam domain.shortcut_data.resolve_emulator_invocation(rom, emulator) takes the resolved EmulatorInvocation and renders the invocation:

  • libretro invocation → the RetroArch -e form:

    flatpak run net.retrodeck.retrodeck -e "%EMULATOR_RETROARCH% -L /var/config/retroarch/cores/<core>.so %ROM%"
    
  • standalone invocation → the emulator's full ES-DE command baked verbatim:

    flatpak run net.retrodeck.retrodeck -e "%EMULATOR_RPCS3% --no-gui %ROM%"
    
  • emulator is None → the plain flatpak run net.retrodeck.retrodeck.

%EMULATOR_*% and %ROM% stay as ES-DE placeholders — RetroDECK's run_game.sh resolves and single-quotes them at launch, so a ROM path with spaces or parens is handled. For the libretro form, only the in-sandbox cores directory (/var/config/retroarch/cores) is baked literally; ES-DE's %CORE_RETROARCH% variable is not expanded through -e, so the plugin bakes the resolved path itself (a standalone command carries no %CORE_RETROARCH%, so it bakes as-is). The -e flag makes RetroDECK skip its gamelist lookup entirely, which is why a baked emulator applies for any filename (see Why the plugin always bakes the core, never the gamelist).

Always -e. Per ADR-0012, every installed ROM bakes its full resolved active emulator through -e — the per-game pin, the per-platform core, the es_systems libretro default, or a standalone emulator, whichever the resolver returns. The plain flatpak run launch is not the "no override" case any more; it is reserved for the single fallback where the resolver yields None (a platform with no resolvable default at all). Baking the default for every ROM is what lets the plugin own launch selection completely: a launch that is not -e would let RetroDECK consult the gamelist, re-coupling the plugin to ES-DE's state. The cost is that a RetroDECK update changing a platform's default core needs a Force Full Sync to re-bake — see A frozen default needs a Force Full Sync.

The three bake sites

launch_options is written wherever a shortcut's command is (re)built. All three resolve the ROM's full active emulator through the same ActiveCoreResolver.active_emulator_for_rom seam and pass the EmulatorInvocation into resolve_emulator_invocation, so the read-path core and the launched emulator cannot diverge:

Bake site When it runs How it resolves the emulator
ShortcutLaunchResolver → do_build_core_overrides every sync (preview + apply) each ROM through active_emulator_for_rom → {rom_id: EmulatorInvocation} for the bake
DownloadService → _resolve_bound_app_id on download-complete (install/reinstall) the ROM through active_emulator_for_rom in the same flow
MigrationService → _build_relaunch_items on RetroDECK-home migration each relocated ROM through active_emulator_for_rom

The download-complete bake is the one that re-applies a pin after reinstall — the exact path roms storage was chosen to protect. Each site bakes -e for every ROM that resolves to a concrete emulator (libretro or standalone), and the plain launch only when the resolver returns None. A stale LABEL is handled inside the resolver (warn + degrade), so no bake site ever emits None.so.

Multi-disc selection

A multi-disc game (a PS1 RPG across four CDs, say) installs as a folder of disc images. The same bake that carries the core also carries which disc launches — a second per-game deviation that follows the core override's structure point for point. The decision record is ADR-0014; the user-facing guide is Picking a Disc for Multi-Disc Games.

Storage: the disc pick is a basename on the Rom aggregate

roms.selected_disc is a nullable TEXT column added by migration 004_add_selected_disc.sql. It holds the basename of the disc the user pinned (e.g. "Example Quest - Second Journey (USA) (Disc 2).cue"), never a resolved absolute path and never a disc index.

  • NULL = no selection → the ROM follows the default: the install's .m3u when file_path is one (the in-emulator disc-swap default), else the first enumerated disc.
  • It anchors on roms, not rom_installs, so the pick survives uninstall/reinstall and RetroDECK-home migration (per ADR-0007) — the disc folder is gone while uninstalled, but the basename re-resolves the moment it returns.
  • Mutations go through the verb-named aggregate methods Rom.pin_selected_disc(filename) (rejects a blank filename) and Rom.clear_selected_disc(). Only pin/clear ever write the column (SqliteRomRepository.set_selected_disc); it is excluded from the sync UPSERT SET clause — the same _SYNC_COLUMNS tuple that omits emulator_override omits selected_disc — so a re-sync never wipes the pick.

The plugin stores the basename because the absolute path changes across uninstall/reinstall and home migration (a stored path would go stale) and a positional index would silently re-point if a disc file were added, removed, or renamed. The basename re-resolves to the same disc whenever it is present and cleanly registers as stale (→ default

  • WARNING) when it is not.

Disc identity vs the live accept-list

Enumerating a ROM's discs needs two different facts kept separate:

  • Disc identity is format-semantic and hardcoded — domain/disc_formats.py defines DISC_IMAGE_EXTENSIONS = {.cue, .chd, .iso}, the irreducible set of launchable disc-image containers. A .bin is a sidecar (owned by its .cue, never launched directly) and an .m3u is a playlist; both are excluded simply by not being in the set. The disc unit is the .cue/.chd/.iso itself, never the .bin.
  • The per-system accept-list is a capability and read live — AtlasCatalogueAdapter.get_supported_extensions(system) (adapters/atlas_catalogue.py) returns the system's es_systems <extension> set, lowercased, threaded into the resolver through the SystemSupportedExtensionsFn Protocol, exactly as system_supports_m3u is read for the .m3u gate (ADR-0013).

Enumeration keeps the files whose extension is in the intersection of the two, so a disc the emulator cannot launch on this system is never offered. es_systems alone cannot supply disc identity — it is a flat accept-list with no per-token role metadata, so it lists .cue, .bin, and .m3u identically and cannot say which is the disc. When es_systems is unavailable, enumeration falls back to the full disc set rather than intersecting to nothing.

The read seam: DiscLaunchResolver

DiscLaunchResolver (backend/services/disc_launch_resolver.py) is the single place that answers "which file will this multi-disc ROM actually launch with?", mirroring ActiveCoreResolver. It scans the install directory recursively (the file-lister Protocol), reads the live accept-list, enumerates the discs (domain/disc_selection.enumerate_discs), and resolves the persisted selected_disc over them (domain/disc_selection.resolve_launch_path):

resolve_for_install(install, selected_disc):
  discs = enumerate_discs(scan(install.rom_dir), supported_extensions(install.system))
  path =
    if len(discs) < 2:                    ── not multi-disc → file_path unchanged
        install.file_path
    elif selected_disc names a disc:      ── valid pin
        that disc's path
    else:                                 ── NULL, or a stale pin (warn + degrade)
        install.file_path if it ends .m3u else discs[0].path   ── the default
  return folder_boot_root(path, install.rom_dir) or path   ── folder-boot override (ADR-0019)

A non-multi-disc ROM resolves to its own file_path — zero behavior change for the overwhelming majority of games. A stale pin (the selected disc no longer present) degrades to the default with a WARNING, never fatal, exactly like ActiveCoreResolver's stale-label handling. Crucially, the resolver never rewrites file_path: it returns the path to bake, and file_path-derived values (save path, core, displayed filename) stay stable — the same bake-time path-override layering the -e core override uses.

Folder-boot launch target

Some systems boot a game directory, not the nested launch file. A PS3 game installs as a folder whose payload is …/PS3_GAME/USRDIR/EBOOT.BIN, and detect_launch_file picks that EBOOT as file_path — correct as the launch file identity, but RPCS3's directory-boot rejects it and wants the folder that contains PS3_GAME (#1212). Two things change together for such a game — the baked path and the baked invocation form — because they are decided from the same layout fact.

The path. A bake-time path override, layered after disc resolution in the same resolve_bake_path seam: folder_boot_root(path, install.rom_dir) (domain/rom_files.py) strips a hardcoded FOLDER_BOOT_MARKERS run (PS3_GAME/USRDIR/EBOOT.BIN, matched case-sensitively) from the resolved path and returns the game root, guarded so it fires only when rom_dir is set and the derived root stays inside rom_dir (a bare EBOOT in the shared system dir never bakes that shared dir). Because it triggers only when the resolved path still carries the marker, it composes with disc resolution — a resolved disc path (…(Disc 2).cue) has no marker, so a multi-disc ROM is never folder-stripped, and the folder rule applies only when disc resolution returned file_path.

The invocation form. RetroDECK's run_game.sh reinterprets any directory %ROM% as an ES-DE "directory as a file" (game="$game/$(basename "$game")", run_game.sh:63-67), so the standalone -e "%EMULATOR_RPCS3% --no-gui %ROM%" form handed a folder points RPCS3 at a nonexistent …/<Game>/<Game> and never boots. A folder-boot standalone is therefore baked as a direct sandbox invocation that bypasses run_game.sh: flatpak run --command=<launcher> net.retrodeck.retrodeck <args> "<folder>", running the emulator's own launcher inside the sandbox. ActiveCoreResolver.active_emulator_for_rom makes this rewrite: when the resolved emulator is a standalone and the ROM's install is a folder-boot layout (same folder_boot_root fact), it resolves the emulator's sandbox launcher via the SandboxLauncherFn seam (EsFindRulesAdapter.resolve_sandbox_launcher → the /app/retrodeck/components/<x>/… component path) and returns an EmulatorInvocation.direct; resolve_emulator_invocation renders the --command= form, stripping %EMULATOR_*% + %ROM% down to the middle args (--no-gui). A libretro emulator, a non-folder install, or an unresolvable launcher leave the standard run_game -e form untouched.

RomInstall.file_path stays the EBOOT (the ADR-0008 anchor), so save-path, core, and displayed-filename derivations are untouched. Both overrides live in the two shared seams (resolve_bake_path for the path, active_emulator_for_rom for the invocation), so every bake site (the three below plus the download-complete, core set/clear, and startup-reconcile bakes) inherits them with no call-site edits, and a PS3 ROM synced before this change self-heals on its next bake. See ADR-0019.

The same three bake sites — disc path composes with the core

The three sites that re-bake the core override re-bake the disc path through this seam, and the two compose: the disc resolver yields the path, ActiveCoreResolver yields the EmulatorInvocation, and resolve_emulator_invocation(rom, emulator) + build_launch_options(invocation, disc_path) fold them into one command — a per-game core (or standalone emulator) and a pinned disc on the same shortcut coexist.

Bake site How it resolves the disc path
ShortcutLaunchResolver (do_scan_installed_paths / do_read_installed_paths) each installed ROM through resolve_for_install → {rom_id: bake_path} for the bake
DownloadService._resolve_bound_app_id the freshly-installed ROM through resolve_for_install → re-applies the pin on reinstall
MigrationService._build_relaunch_items each relocated ROM through resolve_for_install against the moved install directory

The picker callables

Two service methods on DiscService (backend/services/disc.py) drive the inline DiscSelector dropdown on the game detail page:

  • get_disc_selection(rom_id) reports {multi_disc: false} for an unknown, not-installed, single-file, or fewer-than-two-disc ROM (the frontend renders no picker), else {multi_disc: true, discs: [...], selected, default}. Read-only over the local filesystem; the no-picker answers are normal responses, not failures.
  • select_disc(rom_id, filename) pins a disc (or clears to the default with filename = null). An unknown filename is a hard not_found failure and nothing is written; a non-multi-disc ROM is unsupported; a not-installed ROM is not_installed — all in the canonical {success: false, reason, message} shape. On success it persists the pick via the pin-only set_selected_disc write path, bakes the new disc path folded over the ROM's full active core, and returns the fresh launch_options + the now-effective selected for the frontend to confirm-set on the live shortcut. So the picker's selection and the baked launch command cannot diverge.

Set, clear, and the confirm-before-toast flow

Per-game (CoreService)

The frontend CPU-button menu on the game detail page drives two backend callables:

  • set_game_core(rom_id, label) resolves the LABEL to a bakeable EmulatorInvocation (libretro or standalone) first, via label_to_invocation against the ROM platform's classified command list. A label that does not resolve to a bakeable emulator — unknown, needs_setup, or otherwise un-bakeable — is a hard failure: the canonical {success: False, reason: "core_unavailable", message} shape is returned and nothing is written, so the DB never holds a label no consumer can bake. On success it pins the override, then re-bakes and returns the new launch_options (the -e override form) + the bound app_id for an installed ROM.
  • clear_game_core(rom_id) (triggered by picking the default-marked core in the menu) clears the override to NULL, then re-resolves the ROM's full active core through ActiveCoreResolver and bakes that — the per-platform core or es_systems default, in -e form, not an unconditional plain launch. Because the plugin always bakes -e, "follow the default" still means baking a concrete core; the plain launch appears only when the platform resolves to (None, None). There is no separate "Reset" item — selecting the default-marked entry is the clear path; any other entry pins that core.

For an installed + bound ROM the response carries launch_options + app_id; the frontend then awaits setLaunchOptionsConfirmed (the fire-then-poll AppDetails confirm from ADR-0009) before toasting success. If the confirm fails, a distinct "Core saved — restart Steam to apply" toast shows and the DB row is kept — the next migration/re-sync re-bakes from the pin. An uninstalled or unbound ROM has no live shortcut to update: the pin still lands, launch_options/app_id come back None, and the override applies on the next download/sync.

Per-platform (CoreService.set_system_core)

The platform detail's Change core button — a ButtonItem opening the same context menu as the game-detail picker, so standalone emulators and disabled un-bakeable entries render identically (#1210) — calls set_system_core(platform_slug, core_label):

  1. It writes the choice into settings["platform_cores"] — storing the LABEL under the slug, or popping the slug when the label is empty (revert to the es_systems default) — and persists settings.json through the injected SettingsPersister. The es_systems cache is reset so the next resolution re-reads from disk.
  2. It then fans out a re-bake: it iterates every ROM on the platform and, for each that is installed and shortcut-bound but does not carry a per-game emulator_override (the pin wins over the platform default), resolves the ROM's full active core and appends {app_id, launch_options} to a rebake_items list. ROMs with a per-game pin, uninstalled ROMs, and unbound ROMs are skipped — they have nothing live to rewrite, or their pin already wins.
  3. It returns {success: true, rebake_items}. A failure in either step answers {success: false, reason, message}, and the frontend re-bakes only on success — so a fan-out failure after step 1 leaves the choice stored while no shortcut is re-baked. The write checks no BIOS, so a firmware read cannot add a second way to report a stored switch as failed. The platform detail re-reads the platform once the re-bake is done, and that read answers for the new core's firmware.

The frontend confirm-sets each rebake_items entry on its live Steam shortcut the same way the per-game flow does, so a per-platform core change applies immediately to every installed game on the platform — no sync required. Because the PlatformCoreReaderAdapter holds the live settings dict, the fan-out resolves the value just written rather than a stale snapshot.

The Library page's platform detail reads its core through get_system_core_info(platform_slug), a read of its own rather than a slice of another payload: get_platform_core_info is keyed by ROM and layers that ROM's own pin on top, and get_platform_firmware_status answers whenever the page's walk reaches that platform — which is not when the reader focuses it, and never at all for a platform the page has nothing to say about. It costs one ES-DE options read and a settings.json lookup, opens no Unit of Work, and is issued once per selection.

Its active_core_label, and the identically-named field get_platform_firmware_status carries per platform, are both domain.emulator_commands.resolve_platform_label — the platform-level projection of the read-path precedence: the per-platform override (platform_cores) when it is set and still resolves to a bakeable emulator, else the es_systems default emulator label (the first bakeable command — libretro or standalone). One function, so the two readers cannot drift; a stale override degrades to the default rather than naming an emulator that would not launch, and a standalone default reads its standalone label rather than the libretro system default.

One identity space on the wire

Every payload that names the active emulator names it the same way, and the name is the emulator identity — the resolver's own emulator field, mgba_libretro.so for a libretro entry and DUCKSTATION for a standalone one. Three places carry it and they are the same string:

  • the active_core of get_platform_core_info (per ROM, through active_emulator_for_rom) and of get_platform_firmware_status (per platform, through resolve_platform_option);
  • the emulator field of every picker row in options_to_payload, beside its core_so;
  • the keys of a firmware row's cores map.

That is what lets the BIOS pane join a row's per-emulator lines onto the picker's labels and highlight the one the answer was scoped to (#955). core_so cannot serve any of it: it is None for every standalone emulator and carries no extension for a libretro one, so matching on it named no standalone emulator and printed dolphin_libretro.so where the label said Dolphin. label cannot either — ES-DE lists one pcsx2_libretro.so as both LRPS2 and PCSX2, so a label identifies a launch ROW and two rows can be one emulator. core_so stays on the picker payload because a surface may still need to name the libretro core itself; it is not a second answer to "which emulator is this".

active_core_for_rom's (core_so, label) is therefore not what a payload sends: it answers None for a standalone pick, which is the degradation this swap removed.

Why the plugin always bakes the core, never the gamelist

ES-DE stores core choices in gamelist.xml — a per-game <altemulator> element and a system-level <alternativeEmulator>. The plugin does not use that file for its own launches at all (it neither reads nor writes it), for reasons grounded in on-device testing:

  1. RetroDECK's gamelist lookup is metacharacter-fragile. When the plugin's Steam shortcut launches a ROM with a plain flatpak run command, RetroDECK's run_game.sh matches the ROM path against the gamelist using an awk ~ regex. Any regex metacharacter in the filename ((USA), (Disc 1), [!], …) breaks the match, and the per-game <altemulator> is silently dropped. This is upstream bug #210 / RetroDECK#1358. (ES-DE's own UI resolves <altemulator> itself and bypasses the awk — which is why a core choice can look like it works when launched from ES-DE but not from the plugin's shortcut.)
  2. -e bypasses the lookup. RetroDECK's -e flag sets the emulator invocation directly and skips the gamelist awk block, so the baked core applies regardless of filename. The plugin bakes the resolved core into -e for every installed ROM and is no longer coupled to either the awk bug or ES-DE's folder-collapse display quirks.
  3. A plain launch re-couples the plugin to ES-DE. A non--e launch lets RetroDECK consult the gamelist itself, so a core a user set inside ES-DE's UI would silently affect the plugin's launch — diverging from the BIOS badge, the per-core save path, and the core-change warning that all follow the plugin's resolver. Baking -e for every ROM (ADR-0012) closes that path: the plugin owns core selection end to end, and an ES-DE-set core never reaches a plugin launch.

Writing the gamelist is dropped for the same ownership reason: gamelist.xml is ES-DE's strict-parser-hostile, multi-root-tolerant file, and the per-platform deviation that once lived there now lives in the plugin's own settings.json. There is no gamelist write on any plugin path.

No migration, re-apply once

There is no migration from any old gamelist model. A per-game core previously written to gamelist.xml is not imported (per ADR-0011), and a per-platform core previously set as a system-level <alternativeEmulator> is not imported into platform_cores either — platform_cores starts empty. This is by design: a gamelist-import path would revive the multi-root-XML parse failures and folder-collapse ambiguity the plugin-owned model was chosen to avoid. Re-apply any per-platform core once through the platform detail's Change core button and it sticks from then on.

A frozen default needs a Force Full Sync

Because the es_systems default is baked literally into every shortcut, a RetroDECK update that ships a new default core for a platform does not take effect on a normal sync — a normal sync skips platforms whose ROM set is unchanged, so the previously-baked default survives. A Force Full Sync re-bakes every shortcut and picks up the new default. A core the user sets through the plugin (per-game pin or per-platform picker) re-bakes immediately, so only an externally-changed RetroDECK default carries this caveat.

RetroDECK is the V1 target

The -e flag, the %EMULATOR_RETROARCH% / %ROM% placeholders, and the /var/config/retroarch/cores path are RetroDECK-adapter concerns, isolated at the single seam resolve_emulator_invocation. RetroDECK is the supported launcher for V1 — this is the correct V1 shape, not a placeholder. The per-ROM selection (which emulator does this ROM resolve to?) is a service-layer read; the seam only renders the chosen EmulatorInvocation into a command string. Standalone-emulator support (#129) is the first half of the multi-emulator lift: a standalone emulator is still launched through RetroDECK's -e, so the RetroDECK flatpak invocation remains the single seam — only the -e payload changed (a verbatim ES-DE command instead of the RetroArch -L form). The remaining lift (#918) — a non-RetroDECK launcher behind a Frontend-style port — is net-new work and is not built until a second launcher is concrete.

The core picker now lists standalone emulators alongside libretro cores (#1210 / ADR-0020) — you can choose standalone PCSX2 vs the LRPS2 libretro core in the UI, at both per-game and per-platform scope. BIOS is no longer part of the follow-up: since #1821 a firmware answer is keyed on the emulator identity, so a standalone pick is asked and answered like any libretro core rather than reading active_core = None and degrading. Save-sync for a standalone system is still its own effort.


Related pages: