d6cf5f3d36
- 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>
120 lines
3.9 KiB
TypeScript
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 };
|
|
};
|