feat: migrate launch customization to the RunnerProvider SPI (#7)

Replaces the pre-launch launch-command mutation with an EmulationRunnerProvider
that returns LaunchOverrides (executable + arguments) through the game:runner
SPI. Declares game:runner in class metadata and the shipped manifest (dropping
the now-unused game:launch-hook), and bumps the SDK to ^0.7.0.
This commit is contained in:
BillyOutlast committed 2026-09-17 23:53:59 -04:00
1 parent fe2b51068e
commit 6378073ecb
6 files changed
+1216 -78

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
{
"sdk": "@droposs",
"sdkVersion": "^0.6.1",
"cliVersion": "^0.6.1"
"sdkVersion": "^0.7.0",
"cliVersion": "^0.7.0"
}
+14 -11
View File
@@ -9,7 +9,7 @@
"storageVersion": 1,
"capabilities": [
"ui:slot",
"game:launch-hook",
"game:runner",
"system:command",
"game:fs",
"client:storage"
@@ -22,7 +22,7 @@
"entry": "dist/src/index.js",
"capabilities": [
"ui:slot",
"game:launch-hook",
"game:runner",
"system:command",
"game:fs",
"client:storage"
@@ -40,28 +40,31 @@
"ppsspp-qt"
]
},
"checksum": "9ce7c6ec4c4836739c276f928c96e1b01b23fc1021480e01ca9e9c8a6c663797",
"checksum": "2746c9a322eebd173510d2640ad612a20400de99a1cb5b04c0994e9334b64817",
"files": {
".github/dependabot.yml": "fe156952d1952825377de8e48c90563a3ac19bb95a6e0ced8a2f498a0f20fdf6",
".github/workflows/ci.yml": "60a33a67813097e4744a54b21d8af35e6c0254e56001123a69f34ccf65d1fa8b",
".gitignore": "e8bc935ce727f1809c792f4c5d26c5fc75ba2b7df7fc9390c56c8892f6a6007f",
".github/workflows/release.yml": "08a97b00cac41d321307cb894819c15d357300f950ec01cd51375a12e6604e10",
".gitignore": "455edc14aa6727e0e1c9bf889ca4244f3334bfa8ee3b57af17cb83f9c395ea46",
".sdk-scope.json": "f13936572aca04f0f47da440ed27eb69ea7d5c5b1d0901b69ebd4aa55ce455b6",
"AGENTS.md": "5c40761f860317e9ac81fab0d7da939d579e48fec49b301f5aeac2de2b6c0ccf",
"CLAUDE.md": "3b2c8a6a80fe79fa9ee0680b86d2e84ce0b9255d3dc43eccd4e2fce59064c7da",
"dist/src/emulator.d.ts": "da93a753d5f334af5f3bcd24fe341dafeb7b43ac6ca9457436f40a21246341b7",
"dist/src/emulator.js": "3d5de014c2b2e9783cb83adb0682708044cc01f1a9961da8c4f6749d63dcb7d1",
"dist/src/index.d.ts": "8a71db2ff47abffdd62a70766f138d6a2899eeea0cb051e020fc5c0b27da17ff",
"dist/src/index.js": "9ce7c6ec4c4836739c276f928c96e1b01b23fc1021480e01ca9e9c8a6c663797",
"dist/src/index.d.ts": "3bff35db16ddcaa35cb637be050304cfeebbec266df8f587d899644de51bd31f",
"dist/src/index.js": "2746c9a322eebd173510d2640ad612a20400de99a1cb5b04c0994e9334b64817",
"dist/test/emulation.test.d.ts": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881",
"dist/test/emulation.test.js": "f7d8596199e7367d86f4467e283850b4f0a53a7ddb1aa71b6f784f4c1e08460f",
"dist/test/emulation.test.js": "8599e7e11d75997bf257efaf96daa83ae5d89081073a7e31dc6c0ddac732cb79",
"eslint.config.mjs": "4cdf67d5a07b1a089d8f39e842a7de7532eb519752d60fe5ac706f42fb7bdc6f",
"GEMINI.md": "3b2c8a6a80fe79fa9ee0680b86d2e84ce0b9255d3dc43eccd4e2fce59064c7da",
"package-lock.json": "99c10be18f14b401afe4761eebee82a32e568a3b03108d7e21efdadabea25550",
"package.json": "d1da6db7d790aadc5d088803ddf71585d2d0432a26225e9f97ec86bc8fab6e0a",
"package-lock.json": "11dc51a4144e3043daf35528d416554f5521d239a1ca61ba7fcb89cf0f6f9fb9",
"package.json": "b1e06b7306e8b3481bb01427a4c57f2cd33b44e455741862f630239e288211ba",
"README.md": "398247026f1570e4165c67020d6d9e10c454fc59dc803235c27e756dc5c43b82",
"scripts/switch-sdk-scope.mjs": "cee6513f66458bb65746bb4c27d749f0ab7d28bdb09634acbdda5e8acf4bda5b",
"SECURITY.md": "1ed84c5e2293a1bda1e0d1650df39f5c03dba9a18ee6d8acff78cc10a6897d2d",
"src/emulator.ts": "4b3c109bf0b0a1c6376745217febc259483acc8a58b3a767c3267165f9b40d5a",
"src/index.ts": "977769733600a461bdc3413b10b4f7b9c1ac760835c67818e1fa6427e27aca0b",
"test/emulation.test.ts": "b6e5be7b26bdfe702136ee092b6c09f08257997700677bc03eefe1f3025bbbd8",
"src/index.ts": "bc8307afe2d1f2561b69e4fbb8d7a178f104d271ebfe21672f775b5b5a7696e6",
"test/emulation.test.ts": "13e662becc251cd144b10fc00ee45aaf0d0863867f90021f5eda02416f1ae5bd",
"tsconfig.json": "e67d6d62d5af3c653c4d1af30dea94d529c721708e54bd8609d748be627e79e5"
}
}
+1117 -10
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -12,10 +12,10 @@
"lint": "eslint ."
},
"dependencies": {
"@droposs/plugin-sdk": "^0.6.1"
"@droposs/plugin-sdk": "^0.7.0"
},
"devDependencies": {
"@droposs/plugin-cli": "^0.6.1",
"@droposs/plugin-cli": "^0.7.0",
"typescript": "^5.7.2",
"@types/node": "^22.0.0",
"@eslint/js": "^10.0.1",
+41 -29
View File
@@ -2,6 +2,9 @@ import type {
ClientPlugin,
ClientPluginContext,
LaunchContext,
LaunchOverrides,
RunnerPlatform,
RunnerProvider,
ScopedGameFs,
ClientPluginSystem,
ClientPluginStorage,
@@ -10,7 +13,6 @@ import type {
import {
type EmulatorBinding,
type EmulatorDefinition,
type RomBindingMetadata,
DEFAULT_EMULATORS,
buildLaunchSpec,
discoverEmulators,
@@ -95,14 +97,14 @@ export interface ResolveLaunchDeps {
/**
* Validates the game's ROM binding, discovers an installed/allowlisted
* emulator, and publishes the rewritten launch command on
* `context.metadata.launchCommand` (plus `context.metadata.emulation`).
* Throws to abort the launch pipeline when the binding is broken.
* emulator, and returns the `LaunchOverrides` the host applies to the launch
* command. Returns `null` when the game has no binding. Throws to abort the
* launch pipeline when the binding is broken.
*/
export async function resolveLaunch(
export async function resolveLaunchOverrides(
context: LaunchContext,
deps: ResolveLaunchDeps,
): Promise<RomBindingMetadata | null> {
): Promise<LaunchOverrides | null> {
const binding = await deps.storage.get<EmulatorBinding>(
bindingKey(context.gameId),
);
@@ -132,20 +134,33 @@ export async function resolveLaunch(
}
const spec = buildLaunchSpec(definition, binding);
const metadata: RomBindingMetadata = {
...spec,
romPath: binding.romPath,
coreId: binding.coreId,
};
context.metadata = {
...(context.metadata ?? {}),
launchCommand: spec.commandLine,
emulation: metadata,
};
deps.logger.debug(
`ROM binding ready for ${context.gameTitle}: ${spec.commandLine}`,
);
return metadata;
return { executable: spec.command, arguments: spec.args };
}
/**
* Compatibility runner that rewrites a game's launch to its bound emulator.
* Registered through the host `game:runner` SPI, so no pre-launch hook or core
* launch-command mutation is involved.
*/
export class EmulationRunnerProvider implements RunnerProvider {
id = "drop-emulation";
name = "Emulation & ROM Linking";
supportedPlatforms: RunnerPlatform[] = ["windows", "linux", "macos", "rom"];
constructor(private readonly deps: ResolveLaunchDeps) {}
async detect(): Promise<{ available: boolean; version?: string }> {
// Availability is per-game (the ROM binding); the host calls resolveLaunch
// for every game and we contribute overrides only when one exists.
return { available: true };
}
async resolveLaunch(context: LaunchContext): Promise<LaunchOverrides> {
return (await resolveLaunchOverrides(context, this.deps)) ?? {};
}
}
export default class EmulationPlugin implements ClientPlugin {
@@ -156,7 +171,7 @@ export default class EmulationPlugin implements ClientPlugin {
apiVersion: 2,
capabilities: [
"ui:slot" as const,
"game:launch-hook" as const,
"game:runner" as const,
"system:command" as const,
"game:fs" as const,
"client:storage" as const,
@@ -164,17 +179,14 @@ export default class EmulationPlugin implements ClientPlugin {
};
async init(ctx: ClientPluginContext): Promise<void> {
ctx.registerLaunchHook({
stage: "pre-launch:prepare",
execute: async (context: LaunchContext) => {
await resolveLaunch(context, {
gameFs: ctx.gameFs,
system: ctx.system,
storage: ctx.storage,
logger: ctx.logger,
});
},
});
ctx.registerRunnerProvider?.(
new EmulationRunnerProvider({
gameFs: ctx.gameFs,
system: ctx.system,
storage: ctx.storage,
logger: ctx.logger,
}),
);
ctx.registerSlot("game-detail:panels", createEmulatorPanel(ctx), {
order: 40,
+40 -24
View File
@@ -9,13 +9,13 @@ import Plugin, {
createEmulatorPanel,
discoverEmulators,
expandRomArgs,
resolveLaunch,
resolveLaunchOverrides,
tokenizeCommand,
} from "../src/index.js";
const CAPABILITIES = [
"ui:slot",
"game:launch-hook",
"game:runner",
"system:command",
"game:fs",
"client:storage",
@@ -42,13 +42,14 @@ function deps(ctx: MockClientPluginContext): ResolveLaunchDeps {
};
}
test("drop-emulation registers a launch hook and a game-detail panel", async () => {
test("drop-emulation registers a runner provider and a game-detail panel", async () => {
const ctx = new MockClientPluginContext("drop-emulation", [
...CAPABILITIES,
]);
await new Plugin().init(ctx);
assert.equal(ctx.launchHooks.length, 1);
assert.equal(ctx.launchHooks[0].stage, "pre-launch:prepare");
assert.equal(ctx.launchHooks.length, 0);
assert.equal(ctx.runnerProviders.length, 1);
assert.equal(ctx.runnerProviders[0].id, "drop-emulation");
const panels = ctx.registeredSlots.get("game-detail:panels");
assert.equal(panels?.length, 1);
assert.equal((panels?.[0].component as { name?: string }).name, "EmulatorPanel");
@@ -127,47 +128,62 @@ test("discoverEmulators keeps only allowlisted, working binaries", async () => {
assert.deepEqual(available.map((entry) => entry.id), ["retroarch"]);
});
test("resolveLaunch validates the ROM and rewrites the launch command", async () => {
test("resolveLaunchOverrides validates the ROM and returns launch overrides", async () => {
const ctx = new MockClientPluginContext("drop-emulation", [
...CAPABILITIES,
]);
await ctx.storage.set("binding:g1", BINDING);
await ctx.gameFs.writeFile("g1", BINDING.romPath, "rom-bytes");
const metadata = await resolveLaunch(structuredClone(LAUNCH), deps(ctx));
assert.ok(metadata);
assert.equal(metadata.command, "retroarch");
assert.deepEqual(metadata.args, ["-L", "snes9x", "roms/chrono-trigger.sfc"]);
const context = structuredClone(LAUNCH);
await resolveLaunch(context, deps(ctx));
assert.equal(
context.metadata?.launchCommand,
"retroarch -L snes9x roms/chrono-trigger.sfc",
const overrides = await resolveLaunchOverrides(
structuredClone(LAUNCH),
deps(ctx),
);
assert.deepEqual(
(context.metadata?.emulation as { romPath?: string }).romPath,
assert.ok(overrides);
assert.equal(overrides.executable, "retroarch");
assert.deepEqual(overrides.arguments, [
"-L",
"snes9x",
"roms/chrono-trigger.sfc",
);
]);
assert.deepEqual(ctx.systemCommand.calls[0].args, ["--version"]);
});
test("resolveLaunch is a no-op without a binding and aborts on a missing ROM", async () => {
test("EmulationRunnerProvider contributes overrides through the runner SPI", async () => {
const ctx = new MockClientPluginContext("drop-emulation", [
...CAPABILITIES,
]);
await new Plugin().init(ctx);
await ctx.storage.set("binding:g1", BINDING);
await ctx.gameFs.writeFile("g1", BINDING.romPath, "rom-bytes");
const provider = ctx.runnerProviders[0];
assert.deepEqual(await provider.detect(), { available: true });
const overrides = await provider.resolveLaunch(structuredClone(LAUNCH));
assert.equal(overrides.executable, "retroarch");
assert.deepEqual(overrides.arguments, [
"-L",
"snes9x",
"roms/chrono-trigger.sfc",
]);
});
test("resolveLaunchOverrides is a no-op without a binding and aborts on a missing ROM", async () => {
const ctx = new MockClientPluginContext("drop-emulation", [
...CAPABILITIES,
]);
const context = structuredClone(LAUNCH);
assert.equal(await resolveLaunch(context, deps(ctx)), null);
assert.equal(await resolveLaunchOverrides(context, deps(ctx)), null);
assert.equal(context.metadata, undefined);
await ctx.storage.set("binding:g1", BINDING);
await assert.rejects(
() => resolveLaunch(structuredClone(LAUNCH), deps(ctx)),
() => resolveLaunchOverrides(structuredClone(LAUNCH), deps(ctx)),
/was not found in the game directory/,
);
});
test("resolveLaunch rejects emulators that are not installed or allowlisted", async () => {
test("resolveLaunchOverrides rejects emulators that are not installed or allowlisted", async () => {
const ctx = new MockClientPluginContext("drop-emulation", [
...CAPABILITIES,
]);
@@ -179,7 +195,7 @@ test("resolveLaunch rejects emulators that are not installed or allowlisted", as
stderr: "command not allowlisted",
});
await assert.rejects(
() => resolveLaunch(structuredClone(LAUNCH), deps(ctx)),
() => resolveLaunchOverrides(structuredClone(LAUNCH), deps(ctx)),
/not installed or allowlisted/,
);
});