Files
punktfunk-plugin-rom-manager/plugin/src/domain/launch.ts
T
enricobuehler d6cf5f3d36 refactor: workspace restructure — contract package + pure domain ported, tests green
- bun workspaces: contract/ (Schemas = source of truth: config raw↔resolved,
  domain DTOs, HttpApi contract, API errors) + plugin/ + ui/
- pure domain moved src/{engine,art}→plugin/src/{domain,art}, types now
  imported from @rom-manager/contract and @punktfunk/plugin-kit/wire (the
  hand-copied wire.ts dies), loggers injected instead of module-global
- ui.* and devEntry dropped from the config shape (standalone server is
  gone; stale keys tolerated on decode, dropped by the next save)
- all 48 domain tests ported and green BEFORE any Effect wiring

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:54:38 +02:00

120 lines
3.9 KiB
TypeScript

// 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<string, DetectedEmulator>;
defs: Map<string, EmulatorDef>;
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 };
};