// Resolve a candidate ROM + its effective emulator choice into a `LaunchSpec.value` — the one place a // ROM path becomes a shell command, so it goes through the quoting seam (design §6/§10). A resolution // either succeeds (a command string) or is skipped with a reason the UI/CLI surfaces; `warnings` // carries non-fatal notes (e.g. the RetroArch core isn't installed yet). import type { DetectedEmulator, EmulatorDef } from "./emulators.js"; import type { Platform } from "./platforms.js"; import { type Os, renderCommand } from "./quote.js"; import type { RomCandidate } from "./scanner.js"; /** The effective emulator choice for a title after layering platform default → override → per-game. */ export interface EffectiveLaunch { emulator: string; core?: string; extraArgs?: string; } export type LaunchResolution = | { ok: true; value: string; warnings: string[] } | { ok: false; reason: string }; const coreFileSuffix = (os: Os): string => (os === "windows" ? ".dll" : ".so"); const joinPath = (os: Os, dir: string, name: string): string => os === "windows" ? `${dir}\\${name}` : `${dir}/${name}`; /** Does a template reference a placeholder? */ const usesPlaceholder = (template: string, name: string): boolean => template.includes(`{${name}}`); /** * Resolve one candidate to a launch command. `detected` is the live emulator detection map; `defs` is * the emulator registry (built-ins + operator `custom-*` templates) for detectionless custom emulators. */ export const resolveLaunch = (params: { platform: Platform; candidate: RomCandidate; launch: EffectiveLaunch; detected: Map; defs: Map; os: Os; }): LaunchResolution => { const { candidate, launch, detected, defs, os } = params; const warnings: string[] = []; const det = detected.get(launch.emulator); const def = defs.get(launch.emulator); if (!det && !def) { return { ok: false, reason: `unknown emulator "${launch.emulator}"` }; } const template = det?.template ?? def?.template; if (!template) return { ok: false, reason: `emulator "${launch.emulator}" has no launch template`, }; const supportsArchives = det?.supportsArchives ?? def?.supportsArchives ?? false; // Archive gating (design §5): a `.zip`/`.7z` is only launchable if the emulator can read it. if (candidate.isArchive && !supportsArchives) { return { ok: false, reason: `${candidate.ext} archive but ${launch.emulator} does not support archives`, }; } // The launcher token. Detected emulators carry it; a detectionless custom emulator must bake its // executable into the template (no `{exe}`), else we cannot launch it. let exe = det?.exeToken; if (usesPlaceholder(template, "exe")) { if (!exe) return { ok: false, reason: `emulator "${launch.emulator}" not detected`, }; } else { exe = ""; // template supplies its own launcher } // The core (RetroArch). Build the full core path when we know the cores dir; fall back to the bare // name (RetroArch can resolve it from its own config) with a warning. let core: string | undefined; if (usesPlaceholder(template, "core")) { const coreName = launch.core; if (!coreName) { return { ok: false, reason: `no core configured for ${params.platform.id}`, }; } if (det?.coresDir) { core = joinPath( os, det.coresDir, `${coreName}_libretro${coreFileSuffix(os)}`, ); if (det.cores && !det.cores.includes(coreName)) { warnings.push( `core "${coreName}" not found in ${det.coresDir} (install it in RetroArch)`, ); } } else { core = coreName; warnings.push( `RetroArch cores dir unknown — passing core name "${coreName}" directly`, ); } } const rendered = renderCommand( template, { exe: exe ?? "", rom: candidate.absPath, core }, os, launch.extraArgs, ); if (!rendered.ok) return { ok: false, reason: rendered.reason }; return { ok: true, value: rendered.command, warnings }; };