mirror of
https://github.com/Heretek-Games/drop-emulation.git
synced 2026-11-07 10:05:59 -10:00
Merge pull request #1 from Heretek-Games/chore/sdk-ci
feat(launch): ROM binding validation + command rewrite + registry SDK/CI baseline
This commit is contained in:
9 files changed
+1154
-58
No files matched your search
@@ -0,0 +1,32 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
@@ -5,10 +5,11 @@ Emulator and ROM linking plugin for Drop (#10, #189).
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm test
|
||||
npm ci
|
||||
npm run build
|
||||
npm test
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Built on `@droposs/plugin-sdk` (plugin API v2); the dependency resolves the
|
||||
sibling checkout at `../drop-plugin-sdk/packages/plugin-sdk`.
|
||||
Built on `@droposs/plugin-sdk` (plugin API v2), resolved from the npm registry
|
||||
(currently `^0.4.0`).
|
||||
@@ -2,8 +2,30 @@
|
||||
|
||||
Emulator and ROM linking plugin for Drop (#10, #189).
|
||||
|
||||
## How it works
|
||||
|
||||
Per-game bindings are stored under the client storage key
|
||||
`binding:<gameId>` as `{ emulatorId, coreId, romPath, command? }`. Emulator
|
||||
definitions live under the `emulators` key (falling back to the built-in list
|
||||
in `src/emulator.ts`).
|
||||
|
||||
During the `pre-launch:prepare` stage the plugin:
|
||||
|
||||
1. Looks up the game's binding (no-op when unset).
|
||||
2. Validates the ROM exists through the scoped `game:fs` API.
|
||||
3. Probes configured emulators through the allowlisted `system:command` runner
|
||||
and drops anything missing or failing (`--version` probe).
|
||||
4. Rewrites the launch command, expanding `{core}` and `{rom_path}` in the
|
||||
template, and publishes `metadata.launchCommand` plus
|
||||
`metadata.emulation` on the launch context.
|
||||
|
||||
A `game-detail:panels` slot (`EmulatorPanel`) shows the configured binding.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
pnpm install && pnpm build && pnpm test
|
||||
npm ci
|
||||
npm run build
|
||||
npm test
|
||||
npm run typecheck
|
||||
```
|
||||
+23
-1
@@ -17,5 +17,27 @@
|
||||
"targets": [
|
||||
"client"
|
||||
],
|
||||
"entry": "dist/src/index.js"
|
||||
"entry": "dist/src/index.js",
|
||||
"client": {
|
||||
"entry": "dist/src/index.js",
|
||||
"capabilities": [
|
||||
"ui:slot",
|
||||
"game:launch-hook",
|
||||
"system:command",
|
||||
"game:fs",
|
||||
"client:storage"
|
||||
],
|
||||
"commands": [
|
||||
"retroarch",
|
||||
"dolphin-emu",
|
||||
"duckstation-qt",
|
||||
"pcsx2-qt",
|
||||
"melonds",
|
||||
"mgba-qt",
|
||||
"snes9x-gtk",
|
||||
"mupen64plus",
|
||||
"flycast",
|
||||
"ppsspp-qt"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+561
-33
@@ -9,48 +9,476 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@droposs/plugin-sdk": "file:../drop-plugin-sdk/packages/plugin-sdk"
|
||||
"@droposs/plugin-sdk": "^0.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@droposs/plugin-cli": "file:../drop-plugin-sdk/packages/plugin-cli",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"../drop-plugin-sdk/packages/plugin-cli": {
|
||||
"name": "@droposs/plugin-cli",
|
||||
"version": "0.3.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@droposs/plugin-sdk": "workspace:*",
|
||||
"ajv": "^8.20.0",
|
||||
"esbuild": "^0.28.2"
|
||||
},
|
||||
"bin": {
|
||||
"drop-plugin": "bin/drop-plugin.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"../drop-plugin-sdk/packages/plugin-sdk": {
|
||||
"name": "@droposs/plugin-sdk",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@droposs/plugin-cli": "^0.4.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@droposs/plugin-cli": {
|
||||
"resolved": "../drop-plugin-sdk/packages/plugin-cli",
|
||||
"link": true
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@droposs/plugin-cli/-/plugin-cli-0.4.0.tgz",
|
||||
"integrity": "sha512-3gF/Z3Klf3CLBCtLpPdrzX46G/R+tkMQ9EYtUODD8aRm99jh/dkfalIxVCPQXsTGOph/06UbmJQa2gw9mZ/lag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@droposs/plugin-sdk": "0.4.0",
|
||||
"ajv": "^8.20.0",
|
||||
"esbuild": "^0.28.2"
|
||||
},
|
||||
"bin": {
|
||||
"drop-plugin": "bin/drop-plugin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@droposs/plugin-sdk": {
|
||||
"resolved": "../drop-plugin-sdk/packages/plugin-sdk",
|
||||
"link": true
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@droposs/plugin-sdk/-/plugin-sdk-0.4.0.tgz",
|
||||
"integrity": "sha512-BN9qhONnZPURAzvK0qCnO++5Ie5VenIY6G0kJvP141WfJI/72+a/2ojuKQsGKchSIYo9qnVIHrdmkMB3+GnzhQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.2",
|
||||
@@ -62,6 +490,106 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.2",
|
||||
"@esbuild/win32-ia32": "0.28.2",
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
+3
-2
@@ -7,13 +7,14 @@
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc",
|
||||
"test": "node --test dist/test/*.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"sign": "drop-plugin sign ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@droposs/plugin-sdk": "file:../drop-plugin-sdk/packages/plugin-sdk"
|
||||
"@droposs/plugin-sdk": "^0.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@droposs/plugin-cli": "file:../drop-plugin-sdk/packages/plugin-cli",
|
||||
"@droposs/plugin-cli": "^0.4.0",
|
||||
"typescript": "^5.7.0",
|
||||
"@types/node": "^22.0.0"
|
||||
},
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import type { ClientPluginSystem } from "@droposs/plugin-sdk";
|
||||
|
||||
export interface EmulatorDefinition {
|
||||
/** Bare executable name; must be allowlisted in `client.commands`. */
|
||||
id: string;
|
||||
name: string;
|
||||
/** Command template, e.g. `retroarch -L {core} {rom_path}`. */
|
||||
command: string;
|
||||
/** Extra argument templates appended after the command template. */
|
||||
args?: string[];
|
||||
/** Probe arguments used to detect an installed emulator. */
|
||||
probeArgs?: string[];
|
||||
}
|
||||
|
||||
export interface EmulatorBinding {
|
||||
/** Emulator definition id to launch. */
|
||||
emulatorId: string;
|
||||
/** Libretro core id (used by RetroArch-style `{core}` templates). */
|
||||
coreId: string;
|
||||
/** ROM path relative to the game directory. */
|
||||
romPath: string;
|
||||
/** Optional per-binding command template override. */
|
||||
command?: string;
|
||||
}
|
||||
|
||||
export interface LaunchSpec {
|
||||
emulatorId: string;
|
||||
/** Resolved executable name. */
|
||||
command: string;
|
||||
/** Expanded argument list. */
|
||||
args: string[];
|
||||
/** Display form of the full command line. */
|
||||
commandLine: string;
|
||||
}
|
||||
|
||||
export interface RomBindingMetadata extends LaunchSpec {
|
||||
romPath: string;
|
||||
coreId: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_PROBE_ARGS = ["--version"];
|
||||
|
||||
/** Emulators registered out of the box; keep in sync with `client.commands`. */
|
||||
export const DEFAULT_EMULATORS: EmulatorDefinition[] = [
|
||||
{ id: "retroarch", name: "RetroArch", command: "retroarch -L {core} {rom_path}" },
|
||||
{ id: "dolphin-emu", name: "Dolphin", command: "dolphin-emu -e {rom_path}" },
|
||||
{ id: "duckstation-qt", name: "DuckStation", command: "duckstation-qt {rom_path}" },
|
||||
{ id: "pcsx2-qt", name: "PCSX2", command: "pcsx2-qt {rom_path}" },
|
||||
{ id: "melonds", name: "melonDS", command: "melonds {rom_path}" },
|
||||
{ id: "mgba-qt", name: "mGBA", command: "mgba-qt {rom_path}" },
|
||||
{ id: "snes9x-gtk", name: "Snes9x", command: "snes9x-gtk {rom_path}" },
|
||||
{ id: "mupen64plus", name: "Mupen64Plus", command: "mupen64plus {rom_path}" },
|
||||
{ id: "flycast", name: "Flycast", command: "flycast {rom_path}" },
|
||||
{ id: "ppsspp-qt", name: "PPSSPP", command: "ppsspp-qt {rom_path}" },
|
||||
];
|
||||
|
||||
/** Expands the ROM placeholders in an emulator launch command. */
|
||||
export function expandRomArgs(command: string, binding: EmulatorBinding): string {
|
||||
return command
|
||||
.replaceAll("{rom_path}", binding.romPath)
|
||||
.replaceAll("{core}", binding.coreId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a command template into tokens. Supports single/double quotes and
|
||||
* backslash escapes so paths containing spaces survive the rewrite.
|
||||
*/
|
||||
export function tokenizeCommand(command: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let quote: '"' | "'" | null = null;
|
||||
let quoted = false;
|
||||
for (let index = 0; index < command.length; index += 1) {
|
||||
const char = command[index];
|
||||
if (quote) {
|
||||
if (char === quote) {
|
||||
quote = null;
|
||||
} else if (
|
||||
char === "\\" &&
|
||||
index + 1 < command.length &&
|
||||
(command[index + 1] === quote || command[index + 1] === "\\")
|
||||
) {
|
||||
index += 1;
|
||||
current += command[index];
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
} else if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
quoted = true;
|
||||
} else if (/\s/.test(char)) {
|
||||
if (current.length > 0 || quoted) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
quoted = false;
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (quote) throw new Error("Unterminated quote in emulator command");
|
||||
if (current.length > 0 || quoted) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a binding into a concrete launch spec: expands placeholders in the
|
||||
* template (and any extra args) and splits the result into command + argv.
|
||||
*/
|
||||
export function buildLaunchSpec(
|
||||
definition: EmulatorDefinition,
|
||||
binding: EmulatorBinding,
|
||||
): LaunchSpec {
|
||||
const template = binding.command ?? definition.command;
|
||||
const expanded = expandRomArgs(template, binding);
|
||||
const tokens = tokenizeCommand(expanded);
|
||||
const [command, ...args] = tokens;
|
||||
if (!command) {
|
||||
throw new Error(`Emulator '${definition.id}' has an empty command template`);
|
||||
}
|
||||
const extraArgs = (definition.args ?? []).map((arg) =>
|
||||
expandRomArgs(arg, binding),
|
||||
);
|
||||
return {
|
||||
emulatorId: definition.id,
|
||||
command,
|
||||
args: [...args, ...extraArgs],
|
||||
commandLine: [command, ...args, ...extraArgs].join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes configured emulators through the client host's allowlisted command
|
||||
* runner. Emulators that are missing, not allowlisted, or exit non-zero are
|
||||
* omitted so a broken binding fails before launch.
|
||||
*/
|
||||
export async function discoverEmulators(
|
||||
definitions: EmulatorDefinition[],
|
||||
system: ClientPluginSystem,
|
||||
): Promise<EmulatorDefinition[]> {
|
||||
const available: EmulatorDefinition[] = [];
|
||||
for (const definition of definitions) {
|
||||
try {
|
||||
const result = await system.run(
|
||||
definition.id,
|
||||
definition.probeArgs ?? DEFAULT_PROBE_ARGS,
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
if (result.code === 0) available.push(definition);
|
||||
} catch {
|
||||
// Not installed or not allowlisted: skip.
|
||||
}
|
||||
}
|
||||
return available;
|
||||
}
|
||||
+158
-12
@@ -2,16 +2,150 @@ import type {
|
||||
ClientPlugin,
|
||||
ClientPluginContext,
|
||||
LaunchContext,
|
||||
ScopedGameFs,
|
||||
ClientPluginSystem,
|
||||
ClientPluginStorage,
|
||||
PluginLogger,
|
||||
} from "@droposs/plugin-sdk";
|
||||
import {
|
||||
type EmulatorBinding,
|
||||
type EmulatorDefinition,
|
||||
type RomBindingMetadata,
|
||||
DEFAULT_EMULATORS,
|
||||
buildLaunchSpec,
|
||||
discoverEmulators,
|
||||
} from "./emulator.js";
|
||||
|
||||
export interface EmulatorBinding {
|
||||
coreId: string;
|
||||
romPath: string;
|
||||
export * from "./emulator.js";
|
||||
|
||||
export const BINDING_KEY_PREFIX = "binding:";
|
||||
export const EMULATORS_KEY = "emulators";
|
||||
|
||||
export function bindingKey(gameId: string): string {
|
||||
return `${BINDING_KEY_PREFIX}${gameId}`;
|
||||
}
|
||||
|
||||
/** Expands the ROM placeholder in an emulator launch command. */
|
||||
export function expandRomArgs(command: string, binding: EmulatorBinding): string {
|
||||
return command.replaceAll("{rom_path}", binding.romPath).replaceAll("{core}", binding.coreId);
|
||||
interface EmulatorPanelThis {
|
||||
game?: { id?: string; title?: string };
|
||||
binding: EmulatorBinding | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Game-detail panel component. Uses the options API (no `vue` import) so the
|
||||
* plugin bundle stays framework-agnostic; the host renders `template` inside
|
||||
* the `game-detail:panels` slot.
|
||||
*/
|
||||
export function createEmulatorPanel(ctx: Pick<ClientPluginContext, "storage">) {
|
||||
return {
|
||||
name: "EmulatorPanel",
|
||||
props: { game: { type: Object, required: false } },
|
||||
data(): {
|
||||
binding: EmulatorBinding | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
} {
|
||||
return { binding: null, loading: true, error: "" };
|
||||
},
|
||||
async mounted(this: EmulatorPanelThis) {
|
||||
const gameId = this.game?.id;
|
||||
if (!gameId) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.binding = await ctx.storage.get<EmulatorBinding>(
|
||||
bindingKey(gameId),
|
||||
);
|
||||
} catch (error) {
|
||||
this.error = (error as Error).message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/60 p-4">
|
||||
<h4 class="text-xs font-semibold text-zinc-200">Emulator</h4>
|
||||
<p v-if="loading" class="text-xs text-zinc-400 mt-1">Checking ROM binding…</p>
|
||||
<p v-else-if="error" class="text-xs text-red-400 mt-1">{{ error }}</p>
|
||||
<template v-else-if="binding">
|
||||
<p class="text-xs text-zinc-400 mt-1">
|
||||
Emulator: <span class="font-mono text-zinc-200">{{ binding.emulatorId }}</span>
|
||||
</p>
|
||||
<p v-if="binding.coreId" class="text-xs text-zinc-400 mt-1">
|
||||
Core: <span class="font-mono text-zinc-200">{{ binding.coreId }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-zinc-400 mt-1">
|
||||
ROM: <span class="font-mono text-zinc-200">{{ binding.romPath }}</span>
|
||||
</p>
|
||||
</template>
|
||||
<p v-else class="text-xs text-zinc-400 mt-1">No ROM binding configured for this game.</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResolveLaunchDeps {
|
||||
gameFs: ScopedGameFs;
|
||||
system: ClientPluginSystem;
|
||||
storage: ClientPluginStorage;
|
||||
logger: PluginLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function resolveLaunch(
|
||||
context: LaunchContext,
|
||||
deps: ResolveLaunchDeps,
|
||||
): Promise<RomBindingMetadata | null> {
|
||||
const binding = await deps.storage.get<EmulatorBinding>(
|
||||
bindingKey(context.gameId),
|
||||
);
|
||||
if (!binding) return null;
|
||||
|
||||
const romExists = await deps.gameFs.fileExists(
|
||||
context.gameId,
|
||||
binding.romPath,
|
||||
);
|
||||
if (!romExists) {
|
||||
throw new Error(
|
||||
`ROM '${binding.romPath}' was not found in the game directory`,
|
||||
);
|
||||
}
|
||||
|
||||
const definitions =
|
||||
(await deps.storage.get<EmulatorDefinition[]>(EMULATORS_KEY)) ??
|
||||
DEFAULT_EMULATORS;
|
||||
const available = await discoverEmulators(definitions, deps.system);
|
||||
const definition = available.find(
|
||||
(entry) => entry.id === binding.emulatorId,
|
||||
);
|
||||
if (!definition) {
|
||||
throw new Error(
|
||||
`Emulator '${binding.emulatorId}' is not installed or allowlisted`,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export default class EmulationPlugin implements ClientPlugin {
|
||||
@@ -19,21 +153,33 @@ export default class EmulationPlugin implements ClientPlugin {
|
||||
id: "drop-emulation",
|
||||
name: "Emulation & ROM Linking",
|
||||
version: "0.1.0",
|
||||
apiVersion: 2,
|
||||
capabilities: [
|
||||
"ui:slot" as const,
|
||||
"game:launch-hook" as const,
|
||||
"system:command" as const,
|
||||
"game:fs" as const,
|
||||
"client:storage" as const,
|
||||
],
|
||||
};
|
||||
|
||||
async init(ctx: ClientPluginContext): Promise<void> {
|
||||
ctx.registerLaunchHook({
|
||||
stage: "pre-launch:prepare",
|
||||
execute: async (context: LaunchContext) => {
|
||||
const binding = await ctx.storage.get<EmulatorBinding>(`binding:${context.gameId}`);
|
||||
if (!binding) return;
|
||||
ctx.logger.debug(
|
||||
`ROM binding active for ${context.gameTitle}: ${expandRomArgs("{rom_path}", binding)}`,
|
||||
);
|
||||
await resolveLaunch(context, {
|
||||
gameFs: ctx.gameFs,
|
||||
system: ctx.system,
|
||||
storage: ctx.storage,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
ctx.registerSlot("game-detail:panels", { template: "EmulatorPanel" });
|
||||
ctx.registerSlot("game-detail:panels", createEmulatorPanel(ctx), {
|
||||
order: 40,
|
||||
label: "Emulator",
|
||||
});
|
||||
ctx.logger.info("Emulation plugin initialized");
|
||||
}
|
||||
}
|
||||
+193
-4
@@ -1,16 +1,205 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { MockClientPluginContext } from "@droposs/plugin-sdk";
|
||||
import Plugin, { expandRomArgs } from "../src/index.js";
|
||||
import type { LaunchContext } from "@droposs/plugin-sdk";
|
||||
import Plugin, {
|
||||
type EmulatorBinding,
|
||||
type ResolveLaunchDeps,
|
||||
buildLaunchSpec,
|
||||
createEmulatorPanel,
|
||||
discoverEmulators,
|
||||
expandRomArgs,
|
||||
resolveLaunch,
|
||||
tokenizeCommand,
|
||||
} from "../src/index.js";
|
||||
|
||||
test("drop-emulation registers a launch hook and panel", async () => {
|
||||
const ctx = new MockClientPluginContext("drop-emulation", ["ui:slot", "game:launch-hook", "system:command", "game:fs", "client:storage"]);
|
||||
const CAPABILITIES = [
|
||||
"ui:slot",
|
||||
"game:launch-hook",
|
||||
"system:command",
|
||||
"game:fs",
|
||||
"client:storage",
|
||||
] as const;
|
||||
|
||||
const BINDING: EmulatorBinding = {
|
||||
emulatorId: "retroarch",
|
||||
coreId: "snes9x",
|
||||
romPath: "roms/chrono-trigger.sfc",
|
||||
};
|
||||
|
||||
const LAUNCH: LaunchContext = {
|
||||
gameId: "g1",
|
||||
gameTitle: "Chrono Trigger",
|
||||
gameDir: "/games/ct",
|
||||
};
|
||||
|
||||
function deps(ctx: MockClientPluginContext): ResolveLaunchDeps {
|
||||
return {
|
||||
gameFs: ctx.gameFs,
|
||||
system: ctx.system,
|
||||
storage: ctx.storage,
|
||||
logger: ctx.logger,
|
||||
};
|
||||
}
|
||||
|
||||
test("drop-emulation registers a launch hook 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");
|
||||
const panels = ctx.registeredSlots.get("game-detail:panels");
|
||||
assert.equal(panels?.length, 1);
|
||||
assert.equal((panels?.[0].component as { name?: string }).name, "EmulatorPanel");
|
||||
assert.equal(panels?.[0].label, "Emulator");
|
||||
});
|
||||
|
||||
test("expandRomArgs substitutes placeholders", () => {
|
||||
const command = "retroarch -L {core} {rom_path}";
|
||||
const expanded = expandRomArgs(command, { coreId: "snes9x", romPath: "/roms/game.sfc" });
|
||||
const expanded = expandRomArgs(command, {
|
||||
emulatorId: "retroarch",
|
||||
coreId: "snes9x",
|
||||
romPath: "/roms/game.sfc",
|
||||
});
|
||||
assert.equal(expanded, "retroarch -L snes9x /roms/game.sfc");
|
||||
});
|
||||
|
||||
test("tokenizeCommand respects quotes and escapes", () => {
|
||||
assert.deepEqual(tokenizeCommand('retroarch -L "{core}" "{rom_path}"'), [
|
||||
"retroarch",
|
||||
"-L",
|
||||
"{core}",
|
||||
"{rom_path}",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
tokenizeCommand('"C:\\Program Files\\emu.exe" --rom "my rom.sfc" --flag'),
|
||||
["C:\\Program Files\\emu.exe", "--rom", "my rom.sfc", "--flag"],
|
||||
);
|
||||
assert.deepEqual(tokenizeCommand('emu ""'), ["emu", ""]);
|
||||
assert.throws(() => tokenizeCommand('emu "unterminated'));
|
||||
});
|
||||
|
||||
test("buildLaunchSpec produces the rewritten command line", () => {
|
||||
const spec = buildLaunchSpec(
|
||||
{
|
||||
id: "retroarch",
|
||||
name: "RetroArch",
|
||||
command: 'retroarch -L "{core}" "{rom_path}"',
|
||||
args: ["--verbose"],
|
||||
},
|
||||
{
|
||||
emulatorId: "retroarch",
|
||||
coreId: "snes9x",
|
||||
romPath: "/roms/Chrono Trigger.sfc",
|
||||
},
|
||||
);
|
||||
assert.equal(spec.command, "retroarch");
|
||||
assert.deepEqual(spec.args, [
|
||||
"-L",
|
||||
"snes9x",
|
||||
"/roms/Chrono Trigger.sfc",
|
||||
"--verbose",
|
||||
]);
|
||||
assert.equal(
|
||||
spec.commandLine,
|
||||
"retroarch -L snes9x /roms/Chrono Trigger.sfc --verbose",
|
||||
);
|
||||
});
|
||||
|
||||
test("discoverEmulators keeps only allowlisted, working binaries", async () => {
|
||||
const ctx = new MockClientPluginContext("drop-emulation", [
|
||||
...CAPABILITIES,
|
||||
]);
|
||||
ctx.systemCommand.fallback = { code: 1, stdout: "", stderr: "not found" };
|
||||
ctx.systemCommand.setResponse("retroarch", ["--version"], {
|
||||
code: 0,
|
||||
stdout: "RetroArch 1.19",
|
||||
stderr: "",
|
||||
});
|
||||
const available = await discoverEmulators(
|
||||
[
|
||||
{ id: "retroarch", name: "RetroArch", command: "retroarch {rom_path}" },
|
||||
{ id: "dolphin-emu", name: "Dolphin", command: "dolphin-emu {rom_path}" },
|
||||
],
|
||||
ctx.system,
|
||||
);
|
||||
assert.deepEqual(available.map((entry) => entry.id), ["retroarch"]);
|
||||
});
|
||||
|
||||
test("resolveLaunch validates the ROM and rewrites the launch command", 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",
|
||||
);
|
||||
assert.deepEqual(
|
||||
(context.metadata?.emulation as { romPath?: string }).romPath,
|
||||
"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 () => {
|
||||
const ctx = new MockClientPluginContext("drop-emulation", [
|
||||
...CAPABILITIES,
|
||||
]);
|
||||
const context = structuredClone(LAUNCH);
|
||||
assert.equal(await resolveLaunch(context, deps(ctx)), null);
|
||||
assert.equal(context.metadata, undefined);
|
||||
|
||||
await ctx.storage.set("binding:g1", BINDING);
|
||||
await assert.rejects(
|
||||
() => resolveLaunch(structuredClone(LAUNCH), deps(ctx)),
|
||||
/was not found in the game directory/,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveLaunch rejects emulators that are not installed or allowlisted", async () => {
|
||||
const ctx = new MockClientPluginContext("drop-emulation", [
|
||||
...CAPABILITIES,
|
||||
]);
|
||||
await ctx.storage.set("binding:g1", BINDING);
|
||||
await ctx.gameFs.writeFile("g1", BINDING.romPath, "rom-bytes");
|
||||
ctx.systemCommand.setResponse("retroarch", ["--version"], {
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "command not allowlisted",
|
||||
});
|
||||
await assert.rejects(
|
||||
() => resolveLaunch(structuredClone(LAUNCH), deps(ctx)),
|
||||
/not installed or allowlisted/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the EmulatorPanel component reads the binding from storage", async () => {
|
||||
const ctx = new MockClientPluginContext("drop-emulation", [
|
||||
...CAPABILITIES,
|
||||
]);
|
||||
await ctx.storage.set("binding:g1", BINDING);
|
||||
const panel = createEmulatorPanel(ctx);
|
||||
assert.equal(panel.name, "EmulatorPanel");
|
||||
assert.match(panel.template, /game-detail|Emulator/);
|
||||
assert.match(panel.template, /binding\.romPath/);
|
||||
|
||||
const state = panel.data();
|
||||
const self = { ...state, game: { id: "g1" } } as typeof state & {
|
||||
game: { id: string };
|
||||
};
|
||||
await panel.mounted.call(self as never);
|
||||
assert.equal(self.loading, false);
|
||||
assert.equal(self.error, "");
|
||||
assert.equal(self.binding?.romPath, BINDING.romPath);
|
||||
});
|
||||
Reference in new issue
Block a user