@wasm-gaming/engine-specs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -0
- package/dist/engine.d.ts +68 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +8 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.d.ts +69 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +8 -0
- package/dist/manifest.js.map +1 -0
- package/dist/validate.d.ts +10 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +79 -0
- package/dist/validate.js.map +1 -0
- package/package.json +30 -0
- package/schema/engine-manifest.schema.json +74 -0
- package/src/engine.ts +74 -0
- package/src/index.ts +32 -0
- package/src/manifest.ts +82 -0
- package/src/validate.ts +92 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @wasm-gaming/engine-specs
|
|
2
|
+
|
|
3
|
+
The **engine contract** for the wasm-gaming ecosystem: the single shared interface
|
|
4
|
+
that every WASM game/emulator engine and the host app conform to. Define it once
|
|
5
|
+
here, depend on it everywhere, and engines can't drift.
|
|
6
|
+
|
|
7
|
+
TypeScript is the source of truth ([src/](src/)); a JSON Schema
|
|
8
|
+
([schema/engine-manifest.schema.json](schema/engine-manifest.schema.json)) mirrors
|
|
9
|
+
the manifest for non-TS consumers; a Zod-based validator lets any CI fail on
|
|
10
|
+
drift.
|
|
11
|
+
|
|
12
|
+
## Two layers
|
|
13
|
+
|
|
14
|
+
**Layer A — declarative ([src/manifest.ts](src/manifest.ts))**
|
|
15
|
+
`EngineManifest` describes what an engine ships and needs: artifacts (`wasm`/`js`),
|
|
16
|
+
runtime `assets` (each with a VFS `mountPath` + optional `validate`), the `input`
|
|
17
|
+
preset, `video` geometry, engine-specific `options` (a JSON Schema), and
|
|
18
|
+
`capabilities`. Everything that differs between engines lives here as data.
|
|
19
|
+
|
|
20
|
+
**Layer B — imperative ([src/engine.ts](src/engine.ts))**
|
|
21
|
+
Every engine package default-exports an `EngineSDK` = `{ manifest, load }`.
|
|
22
|
+
`load(config)` boots the engine and returns an `EngineInstance`
|
|
23
|
+
(`start`/`pause`/`resume`/`reset`/`setInput`/`destroy`, plus capability-gated
|
|
24
|
+
`saveState`/`loadState`/`screenshot`).
|
|
25
|
+
|
|
26
|
+
`EngineConfig.storageNamespace` is an optional host hint for per-game storage
|
|
27
|
+
segregation (for example OPFS folders like `sonic1` and `sonic2`).
|
|
28
|
+
`EngineInstance.purgeStorage()` is an optional method for engines that can remove
|
|
29
|
+
persisted runtime files for the active namespace only.
|
|
30
|
+
|
|
31
|
+
The host drives all engines identically.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
An engine implements the contract:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import type { EngineSDK, EngineManifest } from '@wasm-gaming/engine-specs';
|
|
39
|
+
|
|
40
|
+
export const manifest: EngineManifest = { /* ... */ };
|
|
41
|
+
export async function load(config) { /* ... */ }
|
|
42
|
+
|
|
43
|
+
// Compile-time conformance:
|
|
44
|
+
const _sdk: EngineSDK = { manifest, load };
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
CI (or the host) validates a manifest at runtime:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { validateManifest, assertManifest } from '@wasm-gaming/engine-specs';
|
|
51
|
+
|
|
52
|
+
const { valid, errors } = validateManifest(json);
|
|
53
|
+
// or: assertManifest(json) // throws with a readable error list
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Build
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm install
|
|
60
|
+
make build # tsc → dist/ (.js + .d.ts)
|
|
61
|
+
make typecheck # no emit
|
|
62
|
+
make test # build + node test runner
|
|
63
|
+
make publish-dry-run
|
|
64
|
+
make publish
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Consuming
|
|
68
|
+
|
|
69
|
+
Depend on it by version (published npm) or by git ref. Engines list it as a
|
|
70
|
+
(dev/peer) dependency and import only `type`s at compile time — the SDK itself
|
|
71
|
+
carries no runtime dependency on this package.
|
|
72
|
+
|
|
73
|
+
## Versioning
|
|
74
|
+
|
|
75
|
+
`CONTRACT_VERSION` tracks the shape of the interfaces. Breaking changes bump the
|
|
76
|
+
major; engines declare which contract version they target via their own SemVer.
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT.
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { EngineManifest, KeyMap, InputPreset } from './manifest.js';
|
|
2
|
+
/** Binary asset payload accepted by `load()`. */
|
|
3
|
+
export type AssetData = Uint8Array | ArrayBuffer | string;
|
|
4
|
+
/** Where an engine may persist saves/SRAM. */
|
|
5
|
+
export type PersistMode = 'idbfs' | 'opfs' | null;
|
|
6
|
+
export interface EngineConfig {
|
|
7
|
+
/** Render target. The SDK is responsible for the `#canvas` id if the engine needs it. */
|
|
8
|
+
canvas: HTMLCanvasElement;
|
|
9
|
+
/** Runtime files keyed by `AssetSpec.key`. Required specs must be present. */
|
|
10
|
+
assets: Record<string, AssetData>;
|
|
11
|
+
/** Engine-specific settings, validated against `manifest.options`. */
|
|
12
|
+
options?: Record<string, unknown>;
|
|
13
|
+
/** Persistence backend for saves, when the engine supports it. */
|
|
14
|
+
persist?: PersistMode;
|
|
15
|
+
/**
|
|
16
|
+
* Optional storage namespace under the engine's working dir.
|
|
17
|
+
* Hosts can use this to isolate per-game persisted assets (e.g. sonic1/sonic2).
|
|
18
|
+
*/
|
|
19
|
+
storageNamespace?: string;
|
|
20
|
+
/** Lifecycle/telemetry sink. Handlers must not throw. */
|
|
21
|
+
onEvent?: (e: EngineEvent) => void;
|
|
22
|
+
/** Override artifact locations (e.g. host-fetched Release assets). */
|
|
23
|
+
jsUrl?: string;
|
|
24
|
+
wasmUrl?: string;
|
|
25
|
+
}
|
|
26
|
+
export type EngineEvent = {
|
|
27
|
+
type: 'ready';
|
|
28
|
+
} | {
|
|
29
|
+
type: 'error';
|
|
30
|
+
error: Error;
|
|
31
|
+
} | {
|
|
32
|
+
type: 'exit';
|
|
33
|
+
} | {
|
|
34
|
+
type: 'frame';
|
|
35
|
+
fps: number;
|
|
36
|
+
};
|
|
37
|
+
export interface EngineInstance {
|
|
38
|
+
/** Begin running. `load()` may auto-start; then this is a no-op. */
|
|
39
|
+
start(): void;
|
|
40
|
+
pause(): void;
|
|
41
|
+
resume(): void;
|
|
42
|
+
/** Reset to power-on. May throw if the engine cannot reset in-process. */
|
|
43
|
+
reset(): void;
|
|
44
|
+
/** Swap the active input preset/map at runtime. */
|
|
45
|
+
setInput(map: InputPreset | KeyMap): void;
|
|
46
|
+
saveState?(): Promise<Uint8Array>;
|
|
47
|
+
loadState?(data: Uint8Array): Promise<void>;
|
|
48
|
+
screenshot?(): Promise<Blob>;
|
|
49
|
+
/**
|
|
50
|
+
* Optional targeted purge of persisted runtime files for the active namespace.
|
|
51
|
+
* Engines that do not persist assets can omit this method.
|
|
52
|
+
*/
|
|
53
|
+
purgeStorage?(): {
|
|
54
|
+
data: boolean;
|
|
55
|
+
settings: boolean;
|
|
56
|
+
} | Promise<{
|
|
57
|
+
data: boolean;
|
|
58
|
+
settings: boolean;
|
|
59
|
+
}>;
|
|
60
|
+
/** Tear down: stop the loop, release listeners/DOM, free the instance. */
|
|
61
|
+
destroy(): void;
|
|
62
|
+
}
|
|
63
|
+
/** The default export shape every engine package must provide. */
|
|
64
|
+
export interface EngineSDK {
|
|
65
|
+
manifest: EngineManifest;
|
|
66
|
+
load(config: EngineConfig): Promise<EngineInstance>;
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEzE,iDAAiD;AACjD,MAAM,MAAM,SAAS,GAAG,UAAU,GAAG,WAAW,GAAG,MAAM,CAAC;AAE1D,8CAA8C;AAC9C,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,IAAI,CAAC;AAElD,MAAM,WAAW,YAAY;IAC3B,yFAAyF;IACzF,MAAM,EAAE,iBAAiB,CAAC;IAC1B,8EAA8E;IAC9E,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAClC,sEAAsE;IACtE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,kEAAkE;IAClE,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,yDAAyD;IACzD,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,WAAW,KAAK,IAAI,CAAC;IACnC,sEAAsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,GACjB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAChB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAEnC,MAAM,WAAW,cAAc;IAC7B,oEAAoE;IACpE,KAAK,IAAI,IAAI,CAAC;IACd,KAAK,IAAI,IAAI,CAAC;IACd,MAAM,IAAI,IAAI,CAAC;IACf,0EAA0E;IAC1E,KAAK,IAAI,IAAI,CAAC;IACd,mDAAmD;IACnD,QAAQ,CAAC,GAAG,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CAAC;IAG1C,SAAS,CAAC,IAAI,OAAO,CAAC,UAAU,CAAC,CAAC;IAClC,SAAS,CAAC,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,UAAU,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAE7B;;;OAGG;IACH,YAAY,CAAC,IACT;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAA;KAAE,GACpC,OAAO,CAAC;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IAElD,0EAA0E;IAC1E,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,kEAAkE;AAClE,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,cAAc,CAAC;IACzB,IAAI,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;CACrD"}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Layer B — the imperative contract.
|
|
2
|
+
//
|
|
3
|
+
// Every engine subproject's SDK exposes `{ manifest, load }` (an `EngineSDK`).
|
|
4
|
+
// `load()` boots the engine and returns an `EngineInstance` the host controls.
|
|
5
|
+
// The interface is identical across engines; per-engine differences are absorbed
|
|
6
|
+
// by `manifest` (Layer A) and the `assets`/`options` payloads.
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=engine.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.js","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA,qCAAqC;AACrC,EAAE;AACF,+EAA+E;AAC/E,+EAA+E;AAC/E,iFAAiF;AACjF,+DAA+D"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { EngineManifest, EngineArtifacts, AssetSpec, AssetValidation, VideoSpec, EngineCapabilities, KeyMap, InputPreset, JSONSchema, } from './manifest.js';
|
|
2
|
+
export type { EngineSDK, EngineInstance, EngineConfig, EngineEvent, AssetData, PersistMode, } from './engine.js';
|
|
3
|
+
export { validateManifest, assertManifest } from './validate.js';
|
|
4
|
+
export type { ValidationResult } from './validate.js';
|
|
5
|
+
/** The contract version. Bump on breaking changes to the shapes above. */
|
|
6
|
+
export declare const CONTRACT_VERSION = "0.1.1";
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,YAAY,EACV,cAAc,EACd,eAAe,EACf,SAAS,EACT,eAAe,EACf,SAAS,EACT,kBAAkB,EAClB,MAAM,EACN,WAAW,EACX,UAAU,GACX,MAAM,eAAe,CAAC;AAEvB,YAAY,EACV,SAAS,EACT,cAAc,EACd,YAAY,EACZ,WAAW,EACX,SAAS,EACT,WAAW,GACZ,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACjE,YAAY,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEtD,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB,UAAU,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @wasm-gaming/engine-specs — the engine contract.
|
|
2
|
+
//
|
|
3
|
+
// Layer A (declarative): EngineManifest & friends — what an engine ships/needs.
|
|
4
|
+
// Layer B (imperative): EngineSDK / EngineInstance — how the host drives it.
|
|
5
|
+
// Plus a runtime validator for the manifest.
|
|
6
|
+
export { validateManifest, assertManifest } from './validate.js';
|
|
7
|
+
/** The contract version. Bump on breaking changes to the shapes above. */
|
|
8
|
+
export const CONTRACT_VERSION = '0.1.1';
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,EAAE;AACF,gFAAgF;AAChF,8EAA8E;AAC9E,6CAA6C;AAuB7C,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAGjE,0EAA0E;AAC1E,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAO,CAAC"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** A loose JSON Schema object (Draft 2020-12 subset). Kept dependency-free. */
|
|
2
|
+
export type JSONSchema = Record<string, unknown>;
|
|
3
|
+
/** A gamepad→keyboard key map, or the name of a built-in preset (e.g. "rsdkv4"). */
|
|
4
|
+
export type KeyMap = Record<string, string>;
|
|
5
|
+
export type InputPreset = string;
|
|
6
|
+
/** How the host validates a user-provided asset before mounting it. */
|
|
7
|
+
export interface AssetValidation {
|
|
8
|
+
/** Exact byte length, if the asset has a fixed size (e.g. a known ROM dump). */
|
|
9
|
+
bytes?: number;
|
|
10
|
+
/** Lowercase hex SHA-1 the asset must match. */
|
|
11
|
+
sha1?: string;
|
|
12
|
+
}
|
|
13
|
+
/** A file the host must supply at runtime, mounted into the engine's VFS. */
|
|
14
|
+
export interface AssetSpec {
|
|
15
|
+
/** Stable key the host uses in `EngineConfig.assets` (e.g. "data", "rom", "bios"). */
|
|
16
|
+
key: string;
|
|
17
|
+
/** Absolute VFS path the engine reads from (e.g. "/Data.rsdk", "/roms/neogeo.zip"). */
|
|
18
|
+
mountPath: string;
|
|
19
|
+
/** If true, `load()` must reject when the asset is absent. */
|
|
20
|
+
required: boolean;
|
|
21
|
+
/** Suggested file extensions for a host file picker (e.g. [".rsdk"]). */
|
|
22
|
+
accept?: string[];
|
|
23
|
+
/** Integrity/shape constraints the host may enforce before mounting. */
|
|
24
|
+
validate?: AssetValidation;
|
|
25
|
+
/** Human-readable note shown in host UI. */
|
|
26
|
+
description?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Where the CI-built artifacts live (relative paths or URLs; host resolves them). */
|
|
29
|
+
export interface EngineArtifacts {
|
|
30
|
+
/** The `.wasm` binary. */
|
|
31
|
+
wasm: string;
|
|
32
|
+
/** The JS glue/loader (an ES module factory). */
|
|
33
|
+
js: string;
|
|
34
|
+
/** Optional preloaded Emscripten `.data` package (engines that bundle assets). */
|
|
35
|
+
data?: string;
|
|
36
|
+
}
|
|
37
|
+
/** Native render surface geometry. */
|
|
38
|
+
export interface VideoSpec {
|
|
39
|
+
baseWidth: number;
|
|
40
|
+
baseHeight: number;
|
|
41
|
+
/** Display aspect ratio, e.g. "16:9" or "4:3". Defaults to baseWidth:baseHeight. */
|
|
42
|
+
aspect?: string;
|
|
43
|
+
}
|
|
44
|
+
/** Optional features an engine may or may not implement. Gate calls on these. */
|
|
45
|
+
export interface EngineCapabilities {
|
|
46
|
+
saveStates?: boolean;
|
|
47
|
+
sram?: boolean;
|
|
48
|
+
/** Part of a swappable set of cores for one platform (e.g. Megadrive: JGenesis/BlastEm). */
|
|
49
|
+
coreSelectable?: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface EngineManifest {
|
|
52
|
+
/** Stable engine id, e.g. "rsdkv4" | "fbneo" | "blastem" | "jgenesis" | "smw". */
|
|
53
|
+
id: string;
|
|
54
|
+
/** SemVer of this engine build/contract implementation. */
|
|
55
|
+
version: string;
|
|
56
|
+
/** Human-readable name. */
|
|
57
|
+
name?: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
artifacts: EngineArtifacts;
|
|
60
|
+
/** Files supplied at runtime. May be empty (e.g. engines with baked-in assets). */
|
|
61
|
+
assets: AssetSpec[];
|
|
62
|
+
/** Input preset name or an explicit key map. */
|
|
63
|
+
input: InputPreset | KeyMap;
|
|
64
|
+
video: VideoSpec;
|
|
65
|
+
/** JSON Schema for engine-specific `EngineConfig.options`. */
|
|
66
|
+
options?: JSONSchema;
|
|
67
|
+
capabilities: EngineCapabilities;
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=manifest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAOA,+EAA+E;AAC/E,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEjD,oFAAoF;AACpF,MAAM,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAC5C,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC;AAEjC,uEAAuE;AACvE,MAAM,WAAW,eAAe;IAC9B,gFAAgF;IAChF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,6EAA6E;AAC7E,MAAM,WAAW,SAAS;IACxB,sFAAsF;IACtF,GAAG,EAAE,MAAM,CAAC;IACZ,uFAAuF;IACvF,SAAS,EAAE,MAAM,CAAC;IAClB,8DAA8D;IAC9D,QAAQ,EAAE,OAAO,CAAC;IAClB,yEAAyE;IACzE,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,wEAAwE;IACxE,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,4CAA4C;IAC5C,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,sFAAsF;AACtF,MAAM,WAAW,eAAe;IAC9B,0BAA0B;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,EAAE,EAAE,MAAM,CAAC;IACX,kFAAkF;IAClF,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,sCAAsC;AACtC,MAAM,WAAW,SAAS;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,oFAAoF;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,iFAAiF;AACjF,MAAM,WAAW,kBAAkB;IACjC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,4FAA4F;IAC5F,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,cAAc;IAC7B,kFAAkF;IAClF,EAAE,EAAE,MAAM,CAAC;IACX,2DAA2D;IAC3D,OAAO,EAAE,MAAM,CAAC;IAChB,2BAA2B;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,eAAe,CAAC;IAC3B,mFAAmF;IACnF,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,gDAAgD;IAChD,KAAK,EAAE,WAAW,GAAG,MAAM,CAAC;IAC5B,KAAK,EAAE,SAAS,CAAC;IACjB,8DAA8D;IAC9D,OAAO,CAAC,EAAE,UAAU,CAAC;IACrB,YAAY,EAAE,kBAAkB,CAAC;CAClC"}
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Layer A — the declarative contract.
|
|
2
|
+
//
|
|
3
|
+
// An EngineManifest describes what an engine subproject ships and needs. It drives
|
|
4
|
+
// the host's launcher UI, asset pickers and validation. Everything that differs
|
|
5
|
+
// between engines (Data.rsdk vs a ROM zip + BIOS vs baked-in assets) is expressed
|
|
6
|
+
// as *data* here, so the imperative interface (Layer B) can stay identical.
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=manifest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,EAAE;AACF,mFAAmF;AACnF,gFAAgF;AAChF,kFAAkF;AAClF,4EAA4E"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EngineManifest } from './manifest.js';
|
|
2
|
+
export interface ValidationResult {
|
|
3
|
+
valid: boolean;
|
|
4
|
+
errors: string[];
|
|
5
|
+
}
|
|
6
|
+
/** Validate an unknown value as an EngineManifest. Never throws. */
|
|
7
|
+
export declare function validateManifest(input: unknown): ValidationResult;
|
|
8
|
+
/** Validate and throw on failure. Returns the value narrowed to EngineManifest. */
|
|
9
|
+
export declare function assertManifest(input: unknown): EngineManifest;
|
|
10
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAgED,oEAAoE;AACpE,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,gBAAgB,CAMjE;AAED,mFAAmF;AACnF,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,cAAc,CAO7D"}
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Runtime validation of an EngineManifest against the contract.
|
|
2
|
+
// Engine CIs can run this to fail a build that drifts from the spec; hosts can run
|
|
3
|
+
// it on manifests fetched from Releases before trusting them.
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
const AssetValidationSchema = z.object({
|
|
6
|
+
bytes: z.int().nonnegative().optional(),
|
|
7
|
+
sha1: z.string().optional(),
|
|
8
|
+
}).strict();
|
|
9
|
+
const AssetSpecSchema = z.object({
|
|
10
|
+
key: z.string().min(1),
|
|
11
|
+
mountPath: z.string().regex(/^\//),
|
|
12
|
+
required: z.boolean(),
|
|
13
|
+
accept: z.array(z.string()).optional(),
|
|
14
|
+
validate: AssetValidationSchema.optional(),
|
|
15
|
+
description: z.string().optional(),
|
|
16
|
+
}).strict();
|
|
17
|
+
const InputSchema = z.union([
|
|
18
|
+
z.string(),
|
|
19
|
+
z.record(z.string(), z.string()),
|
|
20
|
+
]);
|
|
21
|
+
const VideoSpecSchema = z.object({
|
|
22
|
+
baseWidth: z.int().positive(),
|
|
23
|
+
baseHeight: z.int().positive(),
|
|
24
|
+
aspect: z.string().optional(),
|
|
25
|
+
}).strict();
|
|
26
|
+
const EngineArtifactsSchema = z.object({
|
|
27
|
+
wasm: z.string(),
|
|
28
|
+
js: z.string(),
|
|
29
|
+
data: z.string().optional(),
|
|
30
|
+
}).strict();
|
|
31
|
+
const EngineCapabilitiesSchema = z.object({
|
|
32
|
+
saveStates: z.boolean().optional(),
|
|
33
|
+
sram: z.boolean().optional(),
|
|
34
|
+
coreSelectable: z.boolean().optional(),
|
|
35
|
+
}).strict();
|
|
36
|
+
const EngineManifestSchema = z.object({
|
|
37
|
+
id: z.string().min(1),
|
|
38
|
+
version: z.string().min(1),
|
|
39
|
+
name: z.string().optional(),
|
|
40
|
+
description: z.string().optional(),
|
|
41
|
+
artifacts: EngineArtifactsSchema,
|
|
42
|
+
assets: z.array(AssetSpecSchema),
|
|
43
|
+
input: InputSchema,
|
|
44
|
+
video: VideoSpecSchema,
|
|
45
|
+
options: z.record(z.string(), z.unknown()).optional(),
|
|
46
|
+
capabilities: EngineCapabilitiesSchema,
|
|
47
|
+
}).strict();
|
|
48
|
+
function formatIssuePath(path) {
|
|
49
|
+
if (path.length === 0)
|
|
50
|
+
return 'manifest';
|
|
51
|
+
let out = 'manifest';
|
|
52
|
+
for (const part of path) {
|
|
53
|
+
if (typeof part === 'number')
|
|
54
|
+
out += `[${part}]`;
|
|
55
|
+
else if (typeof part === 'string')
|
|
56
|
+
out += `.${part}`;
|
|
57
|
+
else
|
|
58
|
+
out += '.[symbol]';
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** Validate an unknown value as an EngineManifest. Never throws. */
|
|
63
|
+
export function validateManifest(input) {
|
|
64
|
+
const result = EngineManifestSchema.safeParse(input);
|
|
65
|
+
if (result.success)
|
|
66
|
+
return { valid: true, errors: [] };
|
|
67
|
+
const errors = result.error.issues.map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`);
|
|
68
|
+
return { valid: false, errors };
|
|
69
|
+
}
|
|
70
|
+
/** Validate and throw on failure. Returns the value narrowed to EngineManifest. */
|
|
71
|
+
export function assertManifest(input) {
|
|
72
|
+
const result = EngineManifestSchema.safeParse(input);
|
|
73
|
+
if (!result.success) {
|
|
74
|
+
const errors = result.error.issues.map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`);
|
|
75
|
+
throw new Error(`Invalid EngineManifest:\n- ${errors.join('\n- ')}`);
|
|
76
|
+
}
|
|
77
|
+
return result.data;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=validate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.js","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wasm-gaming/engine-specs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The wasm-gaming engine contract: the single shared interface every WASM game/emulator engine and the host app conform to. TypeScript types + JSON Schema + a Zod runtime validator.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./schema/engine-manifest.json": "./schema/engine-manifest.schema.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist/",
|
|
16
|
+
"src/",
|
|
17
|
+
"schema/"
|
|
18
|
+
],
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"typescript": "^5.5.0"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/wasm-gaming/engine-specs"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"zod": "^4.4.3"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/wasm-gaming/engine-specs/schema/engine-manifest.json",
|
|
4
|
+
"title": "EngineManifest",
|
|
5
|
+
"description": "The declarative contract an engine subproject ships (mirror of src/manifest.ts).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["id", "version", "artifacts", "assets", "input", "video", "capabilities"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"id": { "type": "string", "minLength": 1 },
|
|
11
|
+
"version": { "type": "string", "minLength": 1 },
|
|
12
|
+
"name": { "type": "string" },
|
|
13
|
+
"description": { "type": "string" },
|
|
14
|
+
"artifacts": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"required": ["wasm", "js"],
|
|
17
|
+
"additionalProperties": false,
|
|
18
|
+
"properties": {
|
|
19
|
+
"wasm": { "type": "string" },
|
|
20
|
+
"js": { "type": "string" },
|
|
21
|
+
"data": { "type": "string" }
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"assets": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"items": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"required": ["key", "mountPath", "required"],
|
|
29
|
+
"additionalProperties": false,
|
|
30
|
+
"properties": {
|
|
31
|
+
"key": { "type": "string", "minLength": 1 },
|
|
32
|
+
"mountPath": { "type": "string", "pattern": "^/" },
|
|
33
|
+
"required": { "type": "boolean" },
|
|
34
|
+
"accept": { "type": "array", "items": { "type": "string" } },
|
|
35
|
+
"validate": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"additionalProperties": false,
|
|
38
|
+
"properties": {
|
|
39
|
+
"bytes": { "type": "integer", "minimum": 0 },
|
|
40
|
+
"sha1": { "type": "string" }
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"description": { "type": "string" }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"input": {
|
|
48
|
+
"oneOf": [
|
|
49
|
+
{ "type": "string" },
|
|
50
|
+
{ "type": "object", "additionalProperties": { "type": "string" } }
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
"video": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"required": ["baseWidth", "baseHeight"],
|
|
56
|
+
"additionalProperties": false,
|
|
57
|
+
"properties": {
|
|
58
|
+
"baseWidth": { "type": "integer", "exclusiveMinimum": 0 },
|
|
59
|
+
"baseHeight": { "type": "integer", "exclusiveMinimum": 0 },
|
|
60
|
+
"aspect": { "type": "string" }
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"options": { "type": "object" },
|
|
64
|
+
"capabilities": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"additionalProperties": false,
|
|
67
|
+
"properties": {
|
|
68
|
+
"saveStates": { "type": "boolean" },
|
|
69
|
+
"sram": { "type": "boolean" },
|
|
70
|
+
"coreSelectable": { "type": "boolean" }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Layer B — the imperative contract.
|
|
2
|
+
//
|
|
3
|
+
// Every engine subproject's SDK exposes `{ manifest, load }` (an `EngineSDK`).
|
|
4
|
+
// `load()` boots the engine and returns an `EngineInstance` the host controls.
|
|
5
|
+
// The interface is identical across engines; per-engine differences are absorbed
|
|
6
|
+
// by `manifest` (Layer A) and the `assets`/`options` payloads.
|
|
7
|
+
|
|
8
|
+
import type { EngineManifest, KeyMap, InputPreset } from './manifest.js';
|
|
9
|
+
|
|
10
|
+
/** Binary asset payload accepted by `load()`. */
|
|
11
|
+
export type AssetData = Uint8Array | ArrayBuffer | string;
|
|
12
|
+
|
|
13
|
+
/** Where an engine may persist saves/SRAM. */
|
|
14
|
+
export type PersistMode = 'idbfs' | 'opfs' | null;
|
|
15
|
+
|
|
16
|
+
export interface EngineConfig {
|
|
17
|
+
/** Render target. The SDK is responsible for the `#canvas` id if the engine needs it. */
|
|
18
|
+
canvas: HTMLCanvasElement;
|
|
19
|
+
/** Runtime files keyed by `AssetSpec.key`. Required specs must be present. */
|
|
20
|
+
assets: Record<string, AssetData>;
|
|
21
|
+
/** Engine-specific settings, validated against `manifest.options`. */
|
|
22
|
+
options?: Record<string, unknown>;
|
|
23
|
+
/** Persistence backend for saves, when the engine supports it. */
|
|
24
|
+
persist?: PersistMode;
|
|
25
|
+
/**
|
|
26
|
+
* Optional storage namespace under the engine's working dir.
|
|
27
|
+
* Hosts can use this to isolate per-game persisted assets (e.g. sonic1/sonic2).
|
|
28
|
+
*/
|
|
29
|
+
storageNamespace?: string;
|
|
30
|
+
/** Lifecycle/telemetry sink. Handlers must not throw. */
|
|
31
|
+
onEvent?: (e: EngineEvent) => void;
|
|
32
|
+
/** Override artifact locations (e.g. host-fetched Release assets). */
|
|
33
|
+
jsUrl?: string;
|
|
34
|
+
wasmUrl?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type EngineEvent =
|
|
38
|
+
| { type: 'ready' }
|
|
39
|
+
| { type: 'error'; error: Error }
|
|
40
|
+
| { type: 'exit' }
|
|
41
|
+
| { type: 'frame'; fps: number };
|
|
42
|
+
|
|
43
|
+
export interface EngineInstance {
|
|
44
|
+
/** Begin running. `load()` may auto-start; then this is a no-op. */
|
|
45
|
+
start(): void;
|
|
46
|
+
pause(): void;
|
|
47
|
+
resume(): void;
|
|
48
|
+
/** Reset to power-on. May throw if the engine cannot reset in-process. */
|
|
49
|
+
reset(): void;
|
|
50
|
+
/** Swap the active input preset/map at runtime. */
|
|
51
|
+
setInput(map: InputPreset | KeyMap): void;
|
|
52
|
+
|
|
53
|
+
// Capability-gated (see manifest.capabilities). Present only when supported.
|
|
54
|
+
saveState?(): Promise<Uint8Array>;
|
|
55
|
+
loadState?(data: Uint8Array): Promise<void>;
|
|
56
|
+
screenshot?(): Promise<Blob>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Optional targeted purge of persisted runtime files for the active namespace.
|
|
60
|
+
* Engines that do not persist assets can omit this method.
|
|
61
|
+
*/
|
|
62
|
+
purgeStorage?():
|
|
63
|
+
| { data: boolean; settings: boolean }
|
|
64
|
+
| Promise<{ data: boolean; settings: boolean }>;
|
|
65
|
+
|
|
66
|
+
/** Tear down: stop the loop, release listeners/DOM, free the instance. */
|
|
67
|
+
destroy(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The default export shape every engine package must provide. */
|
|
71
|
+
export interface EngineSDK {
|
|
72
|
+
manifest: EngineManifest;
|
|
73
|
+
load(config: EngineConfig): Promise<EngineInstance>;
|
|
74
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @wasm-gaming/engine-specs — the engine contract.
|
|
2
|
+
//
|
|
3
|
+
// Layer A (declarative): EngineManifest & friends — what an engine ships/needs.
|
|
4
|
+
// Layer B (imperative): EngineSDK / EngineInstance — how the host drives it.
|
|
5
|
+
// Plus a runtime validator for the manifest.
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
EngineManifest,
|
|
9
|
+
EngineArtifacts,
|
|
10
|
+
AssetSpec,
|
|
11
|
+
AssetValidation,
|
|
12
|
+
VideoSpec,
|
|
13
|
+
EngineCapabilities,
|
|
14
|
+
KeyMap,
|
|
15
|
+
InputPreset,
|
|
16
|
+
JSONSchema,
|
|
17
|
+
} from './manifest.js';
|
|
18
|
+
|
|
19
|
+
export type {
|
|
20
|
+
EngineSDK,
|
|
21
|
+
EngineInstance,
|
|
22
|
+
EngineConfig,
|
|
23
|
+
EngineEvent,
|
|
24
|
+
AssetData,
|
|
25
|
+
PersistMode,
|
|
26
|
+
} from './engine.js';
|
|
27
|
+
|
|
28
|
+
export { validateManifest, assertManifest } from './validate.js';
|
|
29
|
+
export type { ValidationResult } from './validate.js';
|
|
30
|
+
|
|
31
|
+
/** The contract version. Bump on breaking changes to the shapes above. */
|
|
32
|
+
export const CONTRACT_VERSION = '0.1.1';
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Layer A — the declarative contract.
|
|
2
|
+
//
|
|
3
|
+
// An EngineManifest describes what an engine subproject ships and needs. It drives
|
|
4
|
+
// the host's launcher UI, asset pickers and validation. Everything that differs
|
|
5
|
+
// between engines (Data.rsdk vs a ROM zip + BIOS vs baked-in assets) is expressed
|
|
6
|
+
// as *data* here, so the imperative interface (Layer B) can stay identical.
|
|
7
|
+
|
|
8
|
+
/** A loose JSON Schema object (Draft 2020-12 subset). Kept dependency-free. */
|
|
9
|
+
export type JSONSchema = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
/** A gamepad→keyboard key map, or the name of a built-in preset (e.g. "rsdkv4"). */
|
|
12
|
+
export type KeyMap = Record<string, string>;
|
|
13
|
+
export type InputPreset = string;
|
|
14
|
+
|
|
15
|
+
/** How the host validates a user-provided asset before mounting it. */
|
|
16
|
+
export interface AssetValidation {
|
|
17
|
+
/** Exact byte length, if the asset has a fixed size (e.g. a known ROM dump). */
|
|
18
|
+
bytes?: number;
|
|
19
|
+
/** Lowercase hex SHA-1 the asset must match. */
|
|
20
|
+
sha1?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A file the host must supply at runtime, mounted into the engine's VFS. */
|
|
24
|
+
export interface AssetSpec {
|
|
25
|
+
/** Stable key the host uses in `EngineConfig.assets` (e.g. "data", "rom", "bios"). */
|
|
26
|
+
key: string;
|
|
27
|
+
/** Absolute VFS path the engine reads from (e.g. "/Data.rsdk", "/roms/neogeo.zip"). */
|
|
28
|
+
mountPath: string;
|
|
29
|
+
/** If true, `load()` must reject when the asset is absent. */
|
|
30
|
+
required: boolean;
|
|
31
|
+
/** Suggested file extensions for a host file picker (e.g. [".rsdk"]). */
|
|
32
|
+
accept?: string[];
|
|
33
|
+
/** Integrity/shape constraints the host may enforce before mounting. */
|
|
34
|
+
validate?: AssetValidation;
|
|
35
|
+
/** Human-readable note shown in host UI. */
|
|
36
|
+
description?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Where the CI-built artifacts live (relative paths or URLs; host resolves them). */
|
|
40
|
+
export interface EngineArtifacts {
|
|
41
|
+
/** The `.wasm` binary. */
|
|
42
|
+
wasm: string;
|
|
43
|
+
/** The JS glue/loader (an ES module factory). */
|
|
44
|
+
js: string;
|
|
45
|
+
/** Optional preloaded Emscripten `.data` package (engines that bundle assets). */
|
|
46
|
+
data?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Native render surface geometry. */
|
|
50
|
+
export interface VideoSpec {
|
|
51
|
+
baseWidth: number;
|
|
52
|
+
baseHeight: number;
|
|
53
|
+
/** Display aspect ratio, e.g. "16:9" or "4:3". Defaults to baseWidth:baseHeight. */
|
|
54
|
+
aspect?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Optional features an engine may or may not implement. Gate calls on these. */
|
|
58
|
+
export interface EngineCapabilities {
|
|
59
|
+
saveStates?: boolean;
|
|
60
|
+
sram?: boolean;
|
|
61
|
+
/** Part of a swappable set of cores for one platform (e.g. Megadrive: JGenesis/BlastEm). */
|
|
62
|
+
coreSelectable?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface EngineManifest {
|
|
66
|
+
/** Stable engine id, e.g. "rsdkv4" | "fbneo" | "blastem" | "jgenesis" | "smw". */
|
|
67
|
+
id: string;
|
|
68
|
+
/** SemVer of this engine build/contract implementation. */
|
|
69
|
+
version: string;
|
|
70
|
+
/** Human-readable name. */
|
|
71
|
+
name?: string;
|
|
72
|
+
description?: string;
|
|
73
|
+
artifacts: EngineArtifacts;
|
|
74
|
+
/** Files supplied at runtime. May be empty (e.g. engines with baked-in assets). */
|
|
75
|
+
assets: AssetSpec[];
|
|
76
|
+
/** Input preset name or an explicit key map. */
|
|
77
|
+
input: InputPreset | KeyMap;
|
|
78
|
+
video: VideoSpec;
|
|
79
|
+
/** JSON Schema for engine-specific `EngineConfig.options`. */
|
|
80
|
+
options?: JSONSchema;
|
|
81
|
+
capabilities: EngineCapabilities;
|
|
82
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Runtime validation of an EngineManifest against the contract.
|
|
2
|
+
// Engine CIs can run this to fail a build that drifts from the spec; hosts can run
|
|
3
|
+
// it on manifests fetched from Releases before trusting them.
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import type { EngineManifest } from './manifest.js';
|
|
7
|
+
|
|
8
|
+
export interface ValidationResult {
|
|
9
|
+
valid: boolean;
|
|
10
|
+
errors: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const AssetValidationSchema = z.object({
|
|
14
|
+
bytes: z.int().nonnegative().optional(),
|
|
15
|
+
sha1: z.string().optional(),
|
|
16
|
+
}).strict();
|
|
17
|
+
|
|
18
|
+
const AssetSpecSchema = z.object({
|
|
19
|
+
key: z.string().min(1),
|
|
20
|
+
mountPath: z.string().regex(/^\//),
|
|
21
|
+
required: z.boolean(),
|
|
22
|
+
accept: z.array(z.string()).optional(),
|
|
23
|
+
validate: AssetValidationSchema.optional(),
|
|
24
|
+
description: z.string().optional(),
|
|
25
|
+
}).strict();
|
|
26
|
+
|
|
27
|
+
const InputSchema = z.union([
|
|
28
|
+
z.string(),
|
|
29
|
+
z.record(z.string(), z.string()),
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const VideoSpecSchema = z.object({
|
|
33
|
+
baseWidth: z.int().positive(),
|
|
34
|
+
baseHeight: z.int().positive(),
|
|
35
|
+
aspect: z.string().optional(),
|
|
36
|
+
}).strict();
|
|
37
|
+
|
|
38
|
+
const EngineArtifactsSchema = z.object({
|
|
39
|
+
wasm: z.string(),
|
|
40
|
+
js: z.string(),
|
|
41
|
+
data: z.string().optional(),
|
|
42
|
+
}).strict();
|
|
43
|
+
|
|
44
|
+
const EngineCapabilitiesSchema = z.object({
|
|
45
|
+
saveStates: z.boolean().optional(),
|
|
46
|
+
sram: z.boolean().optional(),
|
|
47
|
+
coreSelectable: z.boolean().optional(),
|
|
48
|
+
}).strict();
|
|
49
|
+
|
|
50
|
+
const EngineManifestSchema = z.object({
|
|
51
|
+
id: z.string().min(1),
|
|
52
|
+
version: z.string().min(1),
|
|
53
|
+
name: z.string().optional(),
|
|
54
|
+
description: z.string().optional(),
|
|
55
|
+
artifacts: EngineArtifactsSchema,
|
|
56
|
+
assets: z.array(AssetSpecSchema),
|
|
57
|
+
input: InputSchema,
|
|
58
|
+
video: VideoSpecSchema,
|
|
59
|
+
options: z.record(z.string(), z.unknown()).optional(),
|
|
60
|
+
capabilities: EngineCapabilitiesSchema,
|
|
61
|
+
}).strict();
|
|
62
|
+
|
|
63
|
+
function formatIssuePath(path: PropertyKey[]): string {
|
|
64
|
+
if (path.length === 0) return 'manifest';
|
|
65
|
+
|
|
66
|
+
let out = 'manifest';
|
|
67
|
+
for (const part of path) {
|
|
68
|
+
if (typeof part === 'number') out += `[${part}]`;
|
|
69
|
+
else if (typeof part === 'string') out += `.${part}`;
|
|
70
|
+
else out += '.[symbol]';
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Validate an unknown value as an EngineManifest. Never throws. */
|
|
76
|
+
export function validateManifest(input: unknown): ValidationResult {
|
|
77
|
+
const result = EngineManifestSchema.safeParse(input);
|
|
78
|
+
if (result.success) return { valid: true, errors: [] };
|
|
79
|
+
|
|
80
|
+
const errors = result.error.issues.map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`);
|
|
81
|
+
return { valid: false, errors };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Validate and throw on failure. Returns the value narrowed to EngineManifest. */
|
|
85
|
+
export function assertManifest(input: unknown): EngineManifest {
|
|
86
|
+
const result = EngineManifestSchema.safeParse(input);
|
|
87
|
+
if (!result.success) {
|
|
88
|
+
const errors = result.error.issues.map((issue) => `${formatIssuePath(issue.path)}: ${issue.message}`);
|
|
89
|
+
throw new Error(`Invalid EngineManifest:\n- ${errors.join('\n- ')}`);
|
|
90
|
+
}
|
|
91
|
+
return result.data as EngineManifest;
|
|
92
|
+
}
|