@wasm-gaming/rsdkv4-wasm 0.1.5 → 0.2.1

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.
@@ -1,55 +1,113 @@
1
- // @wasm-gaming/rsdkv4-wasm — SDK entry point.
2
- //
3
- // Conforms to the wasm-gaming engine contract (github.com/wasm-gaming/engine-specs):
4
- // exports `manifest` (declarative) and `load(config)` (imperative).
5
- //
6
- // One game-agnostic rsdkv4.wasm runs both Sonic 1 and Sonic 2 — the difference is
7
- // only which Data.rsdk the host hands us in `config.assets.data`, written into the
8
- // filesystem at runtime (not baked with --preload-file).
9
- //
10
- // Filesystem: built with -sWASMFS (the modern Emscripten filesystem, not MEMFS).
11
- // The game working dir `/data` is mounted on **OPFS** for persistence when the
12
- // page is cross-origin isolated *and* the wasm build can create the OPFS backend
13
- // from the main thread (an Asyncify/JSPI build — today's is not, see WebFS.cpp);
14
- // otherwise it falls back to the WASMFS in-memory backend (transient, but the
15
- // engine still boots). The backend is selectable via
16
- // `config.persist` (see mountWorkingDir), and when the dir is OPFS-backed the SDK
17
- // reuses an already-persisted Data.rsdk/settings.ini instead of re-fetching.
18
- import { manifest } from './rsdkv4.manifest.js';
19
- import { DEFAULT_RSDKV4_OPTIONS } from './rsdkv4.options.js';
20
- export { manifest };
1
+ /**
2
+ * The engine package's entry point: an `EngineSDK` for RSDKv4.
3
+ *
4
+ * ```ts
5
+ * import Rsdkv4SDK from '@wasm-gaming/rsdkv4-wasm';
6
+ *
7
+ * const play = await new Rsdkv4SDK()
8
+ * .mount(document.querySelector('#stage'))
9
+ * .assets({ data: () => library.readBytes('Sonic2') })
10
+ * .storage({ namespace: 'rsdkv4/Sonic2' })
11
+ * .config({ startMenu: 'host' })
12
+ * .on('saves', ({ detail }) => paintSlots(detail))
13
+ * .start({ slot: 0 });
14
+ * ```
15
+ *
16
+ * Everything a host drives is the contract's, and documented there: the four payloads
17
+ * with their descriptors, `mount`, `on`/`off`, `start`, `restart`, `destroy`. What is
18
+ * this engine's own lives in two places — {@link Rsdkv4Config} and friends, which say
19
+ * what the payloads accept *here*, and the extra members of {@link Rsdkv4Play} below:
20
+ * {@link Rsdkv4Play.game} and {@link Rsdkv4Play.devMenu}, for a host that draws
21
+ * RSDKv4's start screens itself, owner-aware {@link Rsdkv4Play.pause}, and
22
+ * {@link Rsdkv4Play.input}, for a host that would rather drive the buttons than let
23
+ * the engine read the keyboard.
24
+ *
25
+ * One game-agnostic `rsdkv4.wasm` runs both Sonic 1 and Sonic 2 — the difference is
26
+ * only which `Data.rsdk` the host hands over in `assets.data`, written into the
27
+ * filesystem at runtime (not baked with `--preload-file`).
28
+ *
29
+ * Filesystem: built with `-sWASMFS`. The working dir is mounted on **OPFS** when the
30
+ * page is cross-origin isolated *and* the build can create the OPFS backend from the
31
+ * main thread (an Asyncify/JSPI build — today's is not, see `WebFS.cpp`); otherwise it
32
+ * falls back to the WASMFS in-memory backend, which boots fine but forgets everything.
33
+ * That fallback is why the SDK mirrors `SData.bin` into OPFS itself: see
34
+ * {@link Rsdkv4Storage.persist}.
35
+ *
36
+ * @module
37
+ * @see [The engine contract](https://wasm-gaming.github.io/engine-specs/api-docs/)
38
+ */
39
+ import { EnginePlayBase, EngineSDKBase, EventEmitter } from '@wasm-gaming/engine-specs';
40
+ import { RSDKV4_BUTTONS, RSDKV4_SPEC, } from './rsdkv4.options.js';
41
+ export { RSDKV4_BUTTONS, RSDKV4_INPUT, RSDKV4_SPEC } from './rsdkv4.options.js';
21
42
  const WORK_ROOT = '/data';
22
43
  const DEFAULT_STORAGE_NAMESPACE = 'default';
44
+ /** RSDKv4's save file, written next to the game data in the working dir. */
45
+ const SAVE_FILE = 'SData.bin';
23
46
  /**
24
47
  * Emscripten's SDL2 port locates the canvas via `document.querySelector('#canvas')`,
25
48
  * so the element the game draws into has to carry this id.
26
49
  */
27
50
  const CANVAS_ID = 'canvas';
28
51
  /**
29
- * Serialize engine options into RSDKv4's settings.ini format.
52
+ * The picture's native size, which is what the SDK scales to fit its mount.
30
53
  *
31
- * Note the Starting* trio defaults to 255 ("unset") — writing 0 is not neutral,
32
- * it tells the engine to skip its start-menu flow and force that stage. See
33
- * DEFAULT_RSDKV4_OPTIONS.
54
+ * A module constant rather than a manifest field: `EngineManifest` left the contract
55
+ * in 0.3.0, and this was one of only two things the SDK ever read back out of it.
34
56
  */
35
- function buildSettingsIni(options = {}) {
36
- const o = { ...DEFAULT_RSDKV4_OPTIONS, ...options };
37
- return [
38
- '[Dev]',
39
- `EngineDebugMode=${o.engineDebugMode ? 'true' : 'false'}`,
40
- `DevMenu=${o.devMenu ? 'true' : 'false'}`,
41
- `StartingCategory=${o.startingCategory | 0}`,
42
- `StartingScene=${o.startingScene | 0}`,
43
- `StartingPlayer=${o.startingPlayer | 0}`,
44
- '',
45
- '[Game]',
46
- `SkipStartMenu=${o.skipStartMenu ? 'true' : 'false'}`,
47
- '',
48
- '[Window]',
49
- `VSync=${o.vsync ? 'true' : 'false'}`,
50
- '',
51
- ].join('\n');
52
- }
57
+ export const RSDKV4_VIDEO = { baseWidth: 424, baseHeight: 240, aspect: '16:9' };
58
+ /**
59
+ * The embind bridges this SDK drives the engine through. All of them ship in the same
60
+ * rsdkv4.wasm, so a module missing any one of them is an artifact older than this SDK
61
+ * rather than a build variant.
62
+ */
63
+ const ENGINE_BRIDGES = [
64
+ 'web_engine_ready',
65
+ 'web_game_type',
66
+ 'web_get_players',
67
+ 'web_get_save_slots',
68
+ 'web_get_game_options',
69
+ 'web_start_game',
70
+ 'web_audio_stop',
71
+ 'web_devmenu_get_stage_list',
72
+ 'web_devmenu_open',
73
+ 'web_input_set_source',
74
+ ];
75
+ /**
76
+ * The name the engine's `web_input_pull()` looks the reader up under, on the module
77
+ * object itself rather than on a global: two engines on one page have two modules,
78
+ * and a global would have them fighting over one claim.
79
+ */
80
+ const INPUT_PULL_HOOK = '__rsdkv4_input_pull';
81
+ /** How often a pull that keeps throwing is allowed to say so. See {@link Rsdkv4Play}. */
82
+ const INPUT_ERROR_INTERVAL_MS = 1000;
83
+ /**
84
+ * Keys whose browser default action is swallowed while the canvas has focus — the
85
+ * arrows and Space scroll the page under the game, Tab takes the focus off the canvas
86
+ * and with it every key the engine was reading.
87
+ *
88
+ * Both `KeyboardEvent.code` and `.key` spellings are in here because the set is
89
+ * matched against each: `code` is the physical key (layout-independent, which is what
90
+ * the engine binds), `key` is what it produced, and a browser or a remapping tool that
91
+ * reports only one of the two still lands.
92
+ *
93
+ * WASD is included because this build's `ProcessInput()` reads those scancodes as a
94
+ * second set of directions. They have no default action to prevent today, so this
95
+ * costs nothing and stops mattering the day the page grows a shortcut on one of them.
96
+ */
97
+ const ENGINE_KEYS = new Set([
98
+ 'ArrowUp',
99
+ 'ArrowDown',
100
+ 'ArrowLeft',
101
+ 'ArrowRight',
102
+ 'KeyW',
103
+ 'KeyA',
104
+ 'KeyS',
105
+ 'KeyD',
106
+ 'Space',
107
+ ' ',
108
+ 'Tab',
109
+ ]);
110
+ // ---------------------------------------------------------------- small helpers
53
111
  function toUint8(x) {
54
112
  if (x == null)
55
113
  return null;
@@ -61,9 +119,15 @@ function toUint8(x) {
61
119
  return new Uint8Array(x);
62
120
  if (ArrayBuffer.isView(x))
63
121
  return new Uint8Array(x.buffer, x.byteOffset, x.byteLength);
64
- throw new TypeError('asset must be Uint8Array | ArrayBuffer | string');
122
+ throw new TypeError('rsdkv4: an asset must be Uint8Array | ArrayBuffer | string');
123
+ }
124
+ /** Bytes for an asset, calling the thunk form only when we get this far. */
125
+ async function resolveAsset(asset) {
126
+ if (asset == null)
127
+ return null;
128
+ return toUint8(typeof asset === 'function' ? await asset() : asset);
65
129
  }
66
- /** Normalize a user-provided storage namespace into a safe relative path. */
130
+ /** Normalize a host-provided storage namespace into a safe relative path. */
67
131
  function normalizeStorageNamespace(namespace) {
68
132
  if (typeof namespace !== 'string' || !namespace.trim())
69
133
  return DEFAULT_STORAGE_NAMESPACE;
@@ -76,6 +140,51 @@ function normalizeStorageNamespace(namespace) {
76
140
  .join('/');
77
141
  return cleaned || DEFAULT_STORAGE_NAMESPACE;
78
142
  }
143
+ /** The plain values behind a descriptor table — what the engine actually needs. */
144
+ function valuesOf(described) {
145
+ const out = {};
146
+ for (const [key, property] of Object.entries(described)) {
147
+ out[key] = property.value;
148
+ }
149
+ return out;
150
+ }
151
+ /**
152
+ * Serialize {@link Rsdkv4Config} into RSDKv4's settings.ini format.
153
+ *
154
+ * The `Starting*` trio is written as 255 — the engine's "unset" — and never anything
155
+ * else. That is not a leftover: anything else there, **including 0**, makes
156
+ * `Engine::Init` skip its normal boot flow and force a stage (see Userdata.cpp). The
157
+ * session's own `stage` is applied after boot instead, through the engine's stage
158
+ * loader, which is why it survives a `restart()`.
159
+ */
160
+ function buildSettingsIni(config) {
161
+ return [
162
+ '[Dev]',
163
+ `EngineDebugMode=${config.engineDebugMode ? 'true' : 'false'}`,
164
+ `DevMenu=${config.devMenu ? 'true' : 'false'}`,
165
+ 'StartingCategory=255',
166
+ 'StartingScene=255',
167
+ 'StartingPlayer=255',
168
+ '',
169
+ '[Game]',
170
+ `SkipStartMenu=${config.startMenu === 'host' ? 'true' : 'false'}`,
171
+ '',
172
+ '[Window]',
173
+ `VSync=${config.vsync ? 'true' : 'false'}`,
174
+ '',
175
+ ].join('\n');
176
+ }
177
+ function sameBytes(a, b) {
178
+ if (a === null || b === null)
179
+ return a === b;
180
+ if (a.length !== b.length)
181
+ return false;
182
+ for (let i = 0; i < a.length; i += 1)
183
+ if (a[i] !== b[i])
184
+ return false;
185
+ return true;
186
+ }
187
+ // ---------------------------------------------------------------- the wasm filesystem
79
188
  /** Best-effort mkdir -p for the Emscripten FS layer. */
80
189
  function ensureDir(Module, path) {
81
190
  const parts = path.split('/').filter(Boolean);
@@ -90,15 +199,25 @@ function ensureDir(Module, path) {
90
199
  }
91
200
  }
92
201
  }
202
+ /** True if `path` exists in the (mounted) filesystem. */
203
+ function fileExists(Module, path) {
204
+ try {
205
+ Module.FS.stat(path);
206
+ return true;
207
+ }
208
+ catch {
209
+ return false;
210
+ }
211
+ }
93
212
  /**
94
213
  * Whether this build can mount OPFS at all, asked of the wasm itself.
95
214
  *
96
- * WASMFS's `wasmfs_create_opfs_backend()` spawns a proxy worker synchronously,
97
- * which it refuses to do on the main browser thread without Asyncify/JSPI — it
98
- * *asserts*, and a failed assert calls `abort()`, which tears the module down for
99
- * good. That is past the point where a try/catch around the call can save us, so
100
- * we have to know before calling. `web_opfs_supported` (WebFS.cpp) mirrors the
101
- * same condition; builds predating that helper simply answer "no".
215
+ * WASMFS's `wasmfs_create_opfs_backend()` spawns a proxy worker synchronously, which it
216
+ * refuses to do on the main browser thread without Asyncify/JSPI — it *asserts*, and a
217
+ * failed assert calls `abort()`, which tears the module down for good. That is past the
218
+ * point where a try/catch around the call can save us, so we have to know before
219
+ * calling. `web_opfs_supported` (WebFS.cpp) mirrors the same condition; builds predating
220
+ * that helper simply answer "no".
102
221
  */
103
222
  function opfsMountSupported(Module) {
104
223
  const probe = Module._web_opfs_supported;
@@ -112,16 +231,12 @@ function opfsMountSupported(Module) {
112
231
  }
113
232
  }
114
233
  /**
115
- * Mount the game working dir, honoring `persist`:
116
- * 'opfs' → force an OPFS (persistent) mount; warn + fall back if unavailable.
117
- * null → force the in-memory WASMFS backend (the MEMFS-equivalent fallback).
118
- * undefined → auto: OPFS when the page is cross-origin isolated, else in-memory.
119
- * ('idbfs' isn't supported under WASMFS and is treated as in-memory.)
234
+ * Mount the game working dir, honouring {@link Rsdkv4Storage.persist}. Returns whether
235
+ * the resulting mount is persistent.
120
236
  *
121
- * Cross-origin isolation is necessary but not sufficient: the mount also needs a
122
- * build whose OPFS backend can run from where we call it (see opfsMountSupported),
123
- * so both gates apply before we touch `web_mount_opfs`.
124
- * Returns whether the resulting mount is persistent.
237
+ * Cross-origin isolation is necessary but not sufficient: the mount also needs a build
238
+ * whose OPFS backend can run from where we call it (see {@link opfsMountSupported}), so
239
+ * both gates apply before we touch `web_mount_opfs`.
125
240
  */
126
241
  function mountWorkingDir(Module, persist) {
127
242
  const isolated = typeof crossOriginIsolated !== 'undefined' && crossOriginIsolated;
@@ -142,20 +257,29 @@ function mountWorkingDir(Module, persist) {
142
257
  ensureDir(Module, WORK_ROOT);
143
258
  return { persistent: false };
144
259
  }
145
- /** True if `path` exists in the (mounted) filesystem. */
146
- function fileExists(Module, path) {
260
+ /**
261
+ * OPFS handle for `<namespace>/`, reached with the plain (async) OPFS API from JS —
262
+ * which, unlike the WASM side's sync access handles, works on the main thread of any
263
+ * page. Returns null when OPFS isn't available at all.
264
+ */
265
+ async function opfsNamespaceDir(namespace, create) {
266
+ if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory)
267
+ return null;
147
268
  try {
148
- Module.FS.stat(path);
149
- return true;
269
+ let dir = await navigator.storage.getDirectory();
270
+ for (const segment of namespace.split('/').filter(Boolean)) {
271
+ dir = await dir.getDirectoryHandle(segment, { create });
272
+ }
273
+ return dir;
150
274
  }
151
275
  catch {
152
- return false;
276
+ return null; // missing (create: false), or blocked
153
277
  }
154
278
  }
155
279
  /**
156
- * Wait for Engine::Init, which runs on the engine's first frame (main.cpp's
157
- * main_loop), not inside callMain. Gives up after ~4s rather than hanging the
158
- * host: everything still works, the first read just comes back empty.
280
+ * Wait for `Engine::Init`, which runs on the engine's first frame (main.cpp's
281
+ * main_loop), not inside `callMain`. Gives up after ~4s rather than hanging the host:
282
+ * everything still works, the first read just comes back empty.
159
283
  */
160
284
  async function waitForEngine(Module, timeoutMs = 4000) {
161
285
  if (typeof Module.web_engine_ready !== 'function')
@@ -169,416 +293,921 @@ async function waitForEngine(Module, timeoutMs = 4000) {
169
293
  console.warn('[rsdkv4] engine did not report ready within %dms', timeoutMs);
170
294
  return false;
171
295
  }
172
- /**
173
- * The embind bridges this SDK drives the engine through. All of them ship in the
174
- * same rsdkv4.wasm, so a module missing any one of them is an artifact older
175
- * than this SDK rather than a build variant.
176
- */
177
- const ENGINE_BRIDGES = [
178
- 'web_engine_ready',
179
- 'web_game_type',
180
- 'web_get_players',
181
- 'web_get_save_slots',
182
- 'web_get_game_options',
183
- 'web_start_game',
184
- 'web_audio_stop',
185
- 'web_devmenu_get_stage_list',
186
- ];
187
296
  function engineBridgeComplete(Module) {
188
297
  return ENGINE_BRIDGES.every((name) => typeof Module?.[name] === 'function');
189
298
  }
190
- /** RSDKv4's save file, written next to the game data in the working dir. */
191
- const SAVE_FILE = 'SData.bin';
192
299
  /**
193
- * OPFS handle for `<namespace>/`, reached with the plain (async) OPFS API from
194
- * JS — which, unlike the WASM side's sync access handles, works on the main
195
- * thread of any page. Returns null when OPFS isn't available at all.
300
+ * The order `web_devmenu_open`'s first argument counts in — the index into this array
301
+ * is what crosses to C++, so it has to stay in step with WebDevMenu.cpp (written by
302
+ * scripts/build.sh). Names on this side, a number on the wire.
196
303
  */
197
- async function opfsNamespaceDir(namespace, create) {
198
- if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory)
199
- return null;
200
- try {
201
- let dir = await navigator.storage.getDirectory();
202
- for (const segment of namespace.split('/').filter(Boolean)) {
203
- dir = await dir.getDirectoryHandle(segment, { create });
204
- }
205
- return dir;
206
- }
207
- catch {
208
- return null; // missing (create: false), or blocked
209
- }
210
- }
304
+ const DEV_MENU_SCREENS = ['main', 'player', 'categories', 'stages'];
305
+ // ---------------------------------------------------------------- the live engine
211
306
  /**
212
- * Mirror for the engine's save file.
307
+ * The running engine.
213
308
  *
214
- * The working dir is in-memory whenever the OPFS *mount* is unavailable (which
215
- * is every build without Asyncify/JSPI — see mountWorkingDir), so SData.bin
216
- * would die with the page and every save slot would read "NEW GAME" forever.
217
- * The host's own OPFS access has no such limitation, so the SDK copies the file
218
- * in before the engine boots and back out as it changes. Saves therefore
219
- * survive reloads even while the mounted working dir does not.
309
+ * Most of it is the contract's, inherited: the four getter/setter pairs, the cumulative
310
+ * merge, the chainable promise, the event channel, and `destroy()`. What this class
311
+ * adds is the four hooks the base leaves to an engine — {@link open}, {@link close},
312
+ * {@link mounted}, {@link patched} — plus the members below, which have no contract
313
+ * equivalent and exist because RSDKv4 has a start menu worth drawing from HTML.
220
314
  */
221
- async function restoreSaveData(Module, namespace, workDir) {
222
- const dir = await opfsNamespaceDir(namespace, false);
223
- if (!dir)
224
- return;
225
- try {
226
- const file = await (await dir.getFileHandle(SAVE_FILE)).getFile();
227
- if (file.size > 0) {
228
- Module.FS.writeFile(`${workDir}/${SAVE_FILE}`, new Uint8Array(await file.arrayBuffer()));
315
+ export class Rsdkv4Play extends EnginePlayBase {
316
+ #module = null;
317
+ /** In flight, so two concurrent `restart()`s do not boot two engines. */
318
+ #booting = null;
319
+ #destroyed = false;
320
+ #canvas = null;
321
+ /** False when the host handed us its own canvas: then we neither size nor remove it. */
322
+ #ownsCanvas = false;
323
+ /** The element the picture has to fit inside, in CSS pixels. */
324
+ #box = null;
325
+ #boxObserver = null;
326
+ #persistent = false;
327
+ #namespace = DEFAULT_STORAGE_NAMESPACE;
328
+ #workDir = `${WORK_ROOT}/${DEFAULT_STORAGE_NAMESPACE}`;
329
+ #mirrorSaves = true;
330
+ #saveTimer = null;
331
+ /** Last bytes mirrored out, so the `saves` event means "changed" and not "checked". */
332
+ #lastSave = null;
333
+ /**
334
+ * Pause state, with an owner.
335
+ *
336
+ * `masterPaused` only freezes the logic loop — the audio callback keeps mixing, so a
337
+ * paused engine went on playing its music behind whatever the host put on top.
338
+ * Pausing therefore has to stop the sound too.
339
+ *
340
+ * The owner matters because pause/resume has several callers: a pause overlay, a
341
+ * start screen, the host itself. Closing an overlay must not resume a game something
342
+ * else had already frozen, so the *first* pause claims ownership and only that
343
+ * owner's `resume()` lifts it.
344
+ */
345
+ #pauseOwner = null;
346
+ /**
347
+ * The host's button object while it holds a claim, by reference — read on the
348
+ * engine's callback, never copied.
349
+ */
350
+ #buttons = null;
351
+ /** The reader handed to the engine, kept so the same function can be uninstalled. */
352
+ #pull = null;
353
+ /** Throttle state for a pull that throws. See {@link Rsdkv4Play.#readButtons}. */
354
+ #pullErrorKey = null;
355
+ #pullErrorAt = 0;
356
+ #pullErrors = 0;
357
+ constructor(init, events) {
358
+ // The spec goes up rather than being declared as a field: a subclass field
359
+ // initialiser runs after `super()`, which would be too late for the base to seed
360
+ // state from it.
361
+ super(RSDKV4_SPEC, init, events);
362
+ }
363
+ // ---- The hooks the contract's base class leaves to an engine ----
364
+ /**
365
+ * Open or reopen a session. The first call brings the wasm up; every call after that
366
+ * reuses it, because RSDKv4 cannot power-cycle in process and does not need to — a
367
+ * session is a save slot, a character and a stage, and all three are reachable on a
368
+ * running engine.
369
+ */
370
+ async open(options) {
371
+ await this.#ensureBooted();
372
+ // Re-applied from the merged state rather than from a patch, which is what makes a
373
+ // claim survive `restart()`: the engine's own input source was just reset by the
374
+ // boot (or never set, on a reopen), and the host's object is still the one it was.
375
+ if (options.buttons !== undefined)
376
+ this.#applyButtons(options.buttons);
377
+ // Absence is "boot the way the engine normally would", so an empty options bag
378
+ // leaves the engine wherever it came up — its own start menu, usually.
379
+ if (options.player !== undefined || options.slot !== undefined) {
380
+ this.game.start(options.slot ?? null, this.#playerIndex(options.player));
381
+ }
382
+ if (options.stage) {
383
+ this.devMenu.loadStage(options.stage.category | 0, options.stage.scene | 0);
229
384
  }
230
385
  }
231
- catch {
232
- /* no save yet */
386
+ /**
387
+ * Stop and release. The base emits `exit` once this resolves.
388
+ *
389
+ * There is no coming back: RSDKv4 cannot be restarted in process, so a later
390
+ * `start()` on the same factory reports that rather than quietly doing nothing.
391
+ */
392
+ async close() {
393
+ this.#destroyed = true;
394
+ if (this.#saveTimer !== null) {
395
+ clearInterval(this.#saveTimer);
396
+ this.#saveTimer = null;
397
+ }
398
+ const Module = this.#module;
399
+ if (Module) {
400
+ // Before anything else: the engine is about to be pumped a few more times on its
401
+ // way down, and there is no reason for those ticks to reach into a host object
402
+ // whose owner has already been told the session is over.
403
+ this.#applyButtons(null);
404
+ // Last chance to keep whatever the player earned this session.
405
+ if (this.#mirrorSaves)
406
+ await this.#mirrorSaveData();
407
+ try {
408
+ Module.pauseMainLoop?.();
409
+ }
410
+ catch {
411
+ /* engine already down */
412
+ }
413
+ this.#applyPaused(true);
414
+ // Freezing the loop does not silence the audio callback — without this the music
415
+ // kept playing after the host went back to its launcher.
416
+ try {
417
+ Module.web_audio_stop?.();
418
+ }
419
+ catch {
420
+ /* engine already down */
421
+ }
422
+ }
423
+ this.#teardownCanvas();
424
+ this.#module = null;
425
+ this.#booting = null;
233
426
  }
234
- }
235
- async function persistSaveData(Module, namespace, workDir) {
236
- let bytes;
237
- try {
238
- bytes = Module.FS.readFile(`${workDir}/${SAVE_FILE}`);
427
+ /**
428
+ * Where the picture goes. Called by the base before the first `open()`, which is what
429
+ * guarantees the canvas exists before Emscripten's SDL2 port goes looking for it.
430
+ *
431
+ * Hand over a `<canvas>` and it is used as-is, untouched apart from the id SDL needs;
432
+ * hand over any other element and the SDK creates a canvas inside it and keeps it
433
+ * scaled to fit. Reparenting a live engine is legal — the same canvas moves.
434
+ */
435
+ async mounted(target) {
436
+ if (this.#canvas) {
437
+ this.#reparent(target);
438
+ return;
439
+ }
440
+ const provided = target instanceof HTMLCanvasElement ? target : null;
441
+ const canvas = provided ?? document.createElement('canvas');
442
+ this.#ownsCanvas = provided === null;
443
+ this.#canvas = canvas;
444
+ this.#box = this.#ownsCanvas ? target : canvas;
445
+ // Emscripten's SDL2 port locates the canvas via document.querySelector('#canvas').
446
+ if (canvas.id !== CANVAS_ID) {
447
+ if (canvas.id) {
448
+ console.warn(`[rsdkv4] renaming the render target's id from "${canvas.id}" to "${CANVAS_ID}" — SDL looks the canvas up by that id.`);
449
+ }
450
+ canvas.id = CANVAS_ID;
451
+ }
452
+ if (this.#ownsCanvas) {
453
+ canvas.style.display = 'block';
454
+ canvas.style.maxWidth = '100%';
455
+ canvas.style.maxHeight = '100%';
456
+ canvas.style.height = 'auto';
457
+ canvas.classList.add('emscripten');
458
+ target.appendChild(canvas);
459
+ }
460
+ // A canvas is not in the tab order and `focus()` on one is a silent no-op until it
461
+ // has a tabindex. -1 makes it focusable without adding a tab stop.
462
+ if (!canvas.hasAttribute('tabindex'))
463
+ canvas.setAttribute('tabindex', '-1');
464
+ // …and the focus ring would draw a browser-blue box around the picture.
465
+ canvas.style.outline = 'none';
466
+ canvas.addEventListener('contextmenu', this.#swallowContextMenu);
467
+ canvas.addEventListener('pointerdown', this.#onPointerDown);
468
+ canvas.addEventListener('keydown', this.#onKeyDown);
469
+ window.addEventListener('resize', this.#onResize);
470
+ // Fullscreen transitions need a refit too.
471
+ document.addEventListener('fullscreenchange', this.#onFullscreenChange);
472
+ if (typeof ResizeObserver !== 'undefined' && this.#box) {
473
+ this.#boxObserver = new ResizeObserver(this.#onResize);
474
+ this.#boxObserver.observe(this.#box);
475
+ }
476
+ this.#fitPicture();
239
477
  }
240
- catch {
241
- return; // the engine hasn't written one
478
+ /**
479
+ * A payload changed on a running engine. Only the GAME OPTIONS rows are `live` here —
480
+ * `config` is `settings.ini`, read once at boot, and `player`/`slot`/`stage` are what
481
+ * a `restart()` is for.
482
+ */
483
+ async patched(kind, patch) {
484
+ if (kind !== 'options')
485
+ return;
486
+ // Ahead of the engine check, unlike everything below it: a claim written onto an
487
+ // engine that has not booted yet is still a claim, and a malformed one should be
488
+ // rejected by the call that made it rather than by the boot three lines later.
489
+ //
490
+ // `undefined` and `null` are not the same key here, and must not be collapsed:
491
+ // absence means "leave the claim as it is" — which is what keeps a later
492
+ // `options({ stage })` from dropping a claim on its way past — and only `null`
493
+ // releases.
494
+ const buttons = patch.buttons;
495
+ if (buttons !== undefined)
496
+ this.#applyButtons(buttons);
497
+ if (!this.#module)
498
+ return;
499
+ const rows = this.game.options();
500
+ for (const [key, value] of Object.entries(patch)) {
501
+ if (key === 'player' || key === 'slot' || key === 'stage' || key === 'buttons')
502
+ continue;
503
+ const row = rows.find((candidate) => candidate.key === key);
504
+ if (!row) {
505
+ console.warn(`[rsdkv4] "${key}" is not a game option this pack declares — ignoring. Read options() for the ones it does.`);
506
+ continue;
507
+ }
508
+ this.game.setOption(row.key, toEngineOption(row, value));
509
+ }
242
510
  }
243
- const dir = await opfsNamespaceDir(namespace, true);
244
- if (!dir)
245
- return;
246
- try {
247
- const writable = await (await dir.getFileHandle(SAVE_FILE, { create: true })).createWritable();
248
- await writable.write(bytes);
249
- await writable.close();
511
+ options(patch) {
512
+ // `arguments.length`, not `patch === undefined`: `options(undefined)` is a write of
513
+ // nothing, not a read.
514
+ if (arguments.length === 0)
515
+ return this.#describeOptions();
516
+ return super.options(patch);
250
517
  }
251
- catch (e) {
252
- console.warn('[rsdkv4] could not persist save data', e);
518
+ async #describeOptions() {
519
+ const described = (await super.options());
520
+ // The claim never reads back, engine or no engine. The base would have written the
521
+ // host's live object into `value` here, and this getter is what a host serialises
522
+ // to save its settings and what it reads to paint a settings screen — neither of
523
+ // which fourteen booleans that change sixty times a second belong in.
524
+ // `play.input.claimed` is the honest question, and it is a different one.
525
+ described.buttons = { ...described.buttons, value: null };
526
+ if (!this.#module)
527
+ return described;
528
+ const out = { ...described };
529
+ const players = this.game.players();
530
+ if (players.length)
531
+ out.player = { ...out.player, enum: players };
532
+ const stages = flattenStages(this.devMenu.getStageList());
533
+ if (stages.length)
534
+ out.stage = { ...out.stage, enum: stages };
535
+ for (const row of this.game.options()) {
536
+ if (row.key === 'player' ||
537
+ row.key === 'slot' ||
538
+ row.key === 'stage' ||
539
+ row.key === 'buttons') {
540
+ console.warn(`[rsdkv4] the pack declares a game option named "${row.key}" — shadowed`);
541
+ continue;
542
+ }
543
+ out[row.key] = {
544
+ value: fromEngineOption(row),
545
+ description: row.label,
546
+ live: true,
547
+ enum: row.type === 'boolean' ? [false, true] : (row.values ?? []),
548
+ };
549
+ }
550
+ return out;
253
551
  }
254
- }
255
- /** Delete a file if present; returns true when something was removed. */
256
- function deleteFileIfExists(Module, path) {
257
- if (!fileExists(Module, path))
258
- return false;
259
- try {
260
- Module.FS.unlink(path);
261
- return true;
552
+ restart(options) {
553
+ return super.restart(options);
262
554
  }
263
- catch {
264
- return false;
555
+ // ---- Members with no contract equivalent ----
556
+ /**
557
+ * Pause, on behalf of `owner`. The first caller to pause owns it and only that
558
+ * owner's `resume()` lifts it, so a pause overlay cannot resume a game a start
559
+ * screen — or the host — had already frozen. Audio stops with the engine.
560
+ *
561
+ * Emits the contract's `pause` event, whose detail carries the owner.
562
+ */
563
+ pause(owner = 'host') {
564
+ if (this.#pauseOwner !== null)
565
+ return; // whoever got there first keeps the claim
566
+ this.#pauseOwner = owner;
567
+ this.#applyPaused(true);
568
+ // A pause is a natural checkpoint for the save mirror.
569
+ if (this.#mirrorSaves)
570
+ void this.#mirrorSaveData();
571
+ this.emit('pause', { owner });
265
572
  }
266
- }
267
- /** Boot the RSDKv4 engine. */
268
- export async function load(config) {
269
- const { assets, onEvent, attachTo } = config;
270
- const options = config.options;
271
- const emit = (event) => {
573
+ resume(owner = 'host') {
574
+ if (this.#pauseOwner !== owner)
575
+ return; // someone else's pause — leave it alone
576
+ this.#pauseOwner = null;
577
+ this.#applyPaused(false);
578
+ this.emit('resume', { owner });
579
+ }
580
+ /** Who is holding the engine paused, or null. */
581
+ get pausedBy() {
582
+ return this.#pauseOwner;
583
+ }
584
+ /** True when the working dir is OPFS-backed rather than in-memory. */
585
+ get persistent() {
586
+ return this.#persistent;
587
+ }
588
+ /** The normalised {@link Rsdkv4Storage.namespace} this session actually used. */
589
+ get namespace() {
590
+ return this.#namespace;
591
+ }
592
+ /**
593
+ * Input ownership, named. `play.input.claim(buttons)` and `play.input.release()`
594
+ * are exactly `options({ buttons })` and `options({ buttons: null })` — same state,
595
+ * same survival across `restart()`, same everything.
596
+ */
597
+ get input() {
598
+ // Captured, because `this` inside an object literal's getter is the literal. A
599
+ // host that holds on to `play.input` and asks later still gets today's answer.
600
+ const play = this;
601
+ return {
602
+ claim: (buttons) => this.options({ buttons }),
603
+ release: () => this.options({ buttons: null }),
604
+ get claimed() {
605
+ return play.#buttons !== null;
606
+ },
607
+ get mask() {
608
+ return play.#engineMask();
609
+ },
610
+ get held() {
611
+ const mask = play.#engineMask();
612
+ const held = {};
613
+ for (let bit = 0; bit < RSDKV4_BUTTONS.length; bit++) {
614
+ held[RSDKV4_BUTTONS[bit]] = (mask & (1 << bit)) !== 0;
615
+ }
616
+ return held;
617
+ },
618
+ };
619
+ }
620
+ /**
621
+ * The engine's own `web_input_get_mask()`, or 0 when there is no engine to ask.
622
+ *
623
+ * Deliberately quiet about a module that does not have the bridge: unlike a claim,
624
+ * which silently does nothing and deserves the console error `#applyButtons` gives
625
+ * it, this is a read — a host polling it once a frame for an overlay would turn one
626
+ * old artifact into sixty messages a second.
627
+ */
628
+ #engineMask() {
629
+ const Module = this.#module;
630
+ if (!Module || typeof Module.web_input_get_mask !== 'function')
631
+ return 0;
632
+ return Module.web_input_get_mask() | 0;
633
+ }
634
+ // ---- Host-driven input ----
635
+ /**
636
+ * Claim or release, on the engine and here. The only writer of {@link #buttons}.
637
+ *
638
+ * Order matters in both directions: the reader is installed before the engine is
639
+ * told to use it, and the engine is told to stop before it is taken away — so there
640
+ * is never a tick where the engine is pulling at something that is not there.
641
+ */
642
+ #applyButtons(buttons) {
643
+ if (buttons !== null)
644
+ validateButtons(buttons);
645
+ const Module = this.#module;
646
+ this.#buttons = buttons;
647
+ // Before a boot there is nothing to tell; `open()` re-applies from the merged
648
+ // state once the engine is up, which is the same path a `restart()` takes.
649
+ if (!Module)
650
+ return;
651
+ if (typeof Module.web_input_set_source !== 'function') {
652
+ console.error('[rsdkv4] web_input_set_source is missing from this build of rsdkv4.wasm — host-driven input needs an engine built from this SDK\'s build.sh. Rebuild it (`make build-wasm`); the engine keeps polling the keyboard until then.');
653
+ return;
654
+ }
655
+ if (buttons === null) {
656
+ Module.web_input_set_source(0);
657
+ delete Module[INPUT_PULL_HOOK];
658
+ this.#pull = null;
659
+ this.#resetPullErrors();
660
+ return;
661
+ }
662
+ this.#pull ??= () => this.#readButtons();
663
+ Module[INPUT_PULL_HOOK] = this.#pull;
664
+ Module.web_input_set_source(1);
665
+ }
666
+ /**
667
+ * The per-tick read. Called from the engine, at the top of its input pass, through
668
+ * `web_input_pull()` — never from a timer and never from anywhere else.
669
+ *
670
+ * **Keep it on the engine's callback.** A read inside a reactive effect would make
671
+ * all fourteen buttons dependencies of that effect (jq79 attributes every read
672
+ * through a store's `get` trap to whichever effect is running), and every keypress
673
+ * would wake it. The call stack here is always rAF → wasm → EM_JS → this, where no
674
+ * effect can be running, and that is an invariant to preserve rather than a
675
+ * coincidence to rely on.
676
+ */
677
+ #readButtons() {
678
+ const buttons = this.#buttons;
679
+ if (!buttons)
680
+ return 0;
272
681
  try {
273
- onEvent?.(event);
682
+ let mask = 0;
683
+ // Indexed, not `Object.entries`: the array's order is the engine's bit order,
684
+ // and a host object's key order is its own business.
685
+ for (let bit = 0; bit < RSDKV4_BUTTONS.length; bit++) {
686
+ if (buttons[RSDKV4_BUTTONS[bit]])
687
+ mask |= 1 << bit;
688
+ }
689
+ this.#resetPullErrors();
690
+ return mask;
274
691
  }
275
- catch {
276
- // A host callback must not break the engine runtime.
692
+ catch (error) {
693
+ this.#reportPullError(error);
694
+ // Everything released, never the last good mask: a repeated mask with a
695
+ // direction held leaves the character running into a pit on its own.
696
+ return 0;
277
697
  }
278
- };
279
- // ----------------------------------------------------------------- render target
280
- const providedCanvas = config.canvasEl ?? config.canvas ?? null;
281
- if (!providedCanvas && !attachTo) {
282
- throw new Error('rsdkv4: config.canvasEl or config.attachTo is required');
283
- }
284
- const ownsCanvas = providedCanvas === null;
285
- const canvas = providedCanvas ?? document.createElement('canvas');
286
- // Emscripten's SDL2 port locates the canvas via document.querySelector('#canvas').
287
- if (canvas.id !== CANVAS_ID) {
288
- if (canvas.id) {
289
- console.warn(`rsdkv4: renaming the render target's id from "${canvas.id}" to "${CANVAS_ID}" — SDL looks the canvas up by that id.`);
290
- }
291
- canvas.id = CANVAS_ID;
292
- }
293
- if (ownsCanvas) {
294
- canvas.style.display = 'block';
295
- canvas.style.maxWidth = '100%';
296
- canvas.style.maxHeight = '100%';
297
- canvas.style.height = 'auto';
298
- canvas.classList.add('emscripten');
299
- attachTo?.appendChild(canvas);
300
- }
301
- // A canvas is not in the tab order and `focus()` on one is a silent no-op
302
- // until it has a tabindex. -1 makes it focusable without adding a tab stop.
303
- if (!canvas.hasAttribute('tabindex'))
304
- canvas.setAttribute('tabindex', '-1');
305
- // …and the focus ring would draw a browser-blue box around the picture.
306
- canvas.style.outline = 'none';
307
- const swallowContextMenu = (event) => event.preventDefault();
308
- canvas.addEventListener('contextmenu', swallowContextMenu);
309
- const focusCanvas = () => {
310
- if (!document.hasFocus())
311
- window.focus();
312
- canvas.focus({ preventScroll: true });
313
- };
314
- const onPointerDown = () => focusCanvas();
315
- canvas.addEventListener('pointerdown', onPointerDown);
316
- // ----------------------------------------------------------------- sizing
317
- /** The box the picture has to fit inside, in CSS pixels. */
318
- const boxElement = ownsCanvas && attachTo ? attachTo : canvas;
319
- const fitPicture = () => {
320
- if (!ownsCanvas)
321
- return;
322
- const box = boxElement.getBoundingClientRect();
323
- if (box.width < 1 || box.height < 1)
698
+ }
699
+ /**
700
+ * Announce a failing pull at most once a second, with a count.
701
+ *
702
+ * This runs sixty times a second, so an unthrottled `emit` would bury the page. It
703
+ * does **not** release the claim: a transient fault recovers on its own, whereas
704
+ * releasing would change input ownership behind the host's back.
705
+ *
706
+ * With a plain object nothing here can fire. It exists because the API accepts any
707
+ * object and a Proxy runs host code on every read — a store nulled by a component
708
+ * that unmounted without releasing, a revoked `Proxy.revocable`, a `get` trap whose
709
+ * effect scope has been torn down. Claim-time validation covers none of those: it
710
+ * validated once, over a reference that was live at the time.
711
+ */
712
+ #reportPullError(error) {
713
+ const key = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
714
+ // performance.now() rather than a timer: nothing to schedule, nothing to clear,
715
+ // and the comparison is already in the hand.
716
+ const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
717
+ this.#pullErrors++;
718
+ if (key !== this.#pullErrorKey || now - this.#pullErrorAt >= INPUT_ERROR_INTERVAL_MS) {
719
+ const windowMs = this.#pullErrorKey === null ? 0 : now - this.#pullErrorAt;
720
+ this.emit('error', Object.assign(new Error(`rsdkv4: reading the host's buttons failed (${this.#pullErrors}× in ${Math.round(windowMs)}ms) — input is dead until it recovers, and the claim is still held. Release it with options({ buttons: null }) if the host is gone. Cause: ${key}`), { cause: error }));
721
+ this.#pullErrorKey = key;
722
+ this.#pullErrorAt = now;
723
+ this.#pullErrors = 0;
724
+ }
725
+ }
726
+ /** Forget the streak, so a relapse announces itself immediately rather than in a second. */
727
+ #resetPullErrors() {
728
+ if (this.#pullErrorKey === null)
324
729
  return;
325
- const bw = manifest.video.baseWidth;
326
- const bh = manifest.video.baseHeight;
327
- const scale = Math.min(box.width / bw, box.height / bh);
328
- canvas.style.width = `${Math.round(bw * scale)}px`;
329
- canvas.style.height = `${Math.round(bh * scale)}px`;
330
- };
331
- fitPicture();
332
- const onResize = () => fitPicture();
333
- window.addEventListener('resize', onResize);
334
- const boxObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize);
335
- boxObserver?.observe(boxElement);
336
- // Fullscreen transitions need a refit too.
337
- const onFullscreenChange = () => {
338
- fitPicture();
339
- requestAnimationFrame(fitPicture);
340
- };
341
- document.addEventListener('fullscreenchange', onFullscreenChange);
342
- // ----------------------------------------------------------------- emscripten
343
- const jsUrl = config.jsUrl ?? new URL('./rsdkv4.js', import.meta.url).href;
344
- const wasmUrl = config.wasmUrl ?? new URL('./rsdkv4.wasm', import.meta.url).href;
345
- // RSDKv4 keyboard bindings are the shared input script's default preset.
346
- if (typeof window !== 'undefined')
347
- window.__gamepadKeyMap = manifest.input;
348
- const instantiate = async (bust) => {
349
- const withBust = (url) => {
350
- if (!bust)
351
- return url;
352
- const parsed = new URL(url, location.href);
353
- parsed.searchParams.set('_', bust);
354
- return parsed.href;
730
+ this.#pullErrorKey = null;
731
+ this.#pullErrorAt = 0;
732
+ this.#pullErrors = 0;
733
+ }
734
+ /** RSDKv4's Start Menu as data. Available once the engine is up. */
735
+ get game() {
736
+ const Module = this.#requireModule();
737
+ const parse = (fn, fallback, what) => parseJson(fn, fallback, what);
738
+ return {
739
+ type: () => (typeof Module.web_game_type === 'function' ? Module.web_game_type() : 0),
740
+ players: () => parse(Module.web_get_players, [], 'web_get_players'),
741
+ saveSlots: () => parse(Module.web_get_save_slots, [], 'web_get_save_slots'),
742
+ options: () => parse(Module.web_get_game_options, [], 'web_get_game_options'),
743
+ setOption: (key, value) => {
744
+ Module.web_set_game_option?.(String(key), value | 0);
745
+ },
746
+ deleteSave: (slot) => {
747
+ Module.web_delete_save?.(slot | 0);
748
+ if (this.#mirrorSaves)
749
+ void this.#mirrorSaveData();
750
+ },
751
+ start: (slot, player = 0) => {
752
+ Module.web_start_game?.(slot == null ? -1 : slot | 0, player | 0);
753
+ // Whatever drove this is done with the engine — hand it back.
754
+ this.#pauseOwner = null;
755
+ this.#applyPaused(false);
756
+ },
355
757
  };
356
- const mod = await import(/* @vite-ignore */ withBust(jsUrl));
357
- return mod.default({
358
- canvas,
359
- noInitialRun: true, // built with -sINVOKE_RUN=0; we mount data before main()
360
- locateFile: (path) => (path.endsWith('.wasm') ? withBust(wasmUrl) : path),
361
- print: (...a) => console.log('[rsdkv4]', ...a),
362
- printErr: (...a) => console.error('[rsdkv4]', ...a),
363
- onAbort: (reason) => emit({ type: 'error', error: new Error(`rsdkv4 aborted: ${reason}`) }),
364
- });
365
- };
366
- let Module = await instantiate();
367
- // Self-heal a stale engine.
368
- //
369
- // rsdkv4.js/.wasm are big and served by most static hosts with nothing but
370
- // Last-Modified, so a browser will happily keep a build for hours. The
371
- // symptom is nasty: fresh SDK code against an old engine, where the bridges
372
- // this SDK calls simply don't exist and every getter quietly answers "empty" —
373
- // no save slots, no characters, an empty pause menu. Rather than leave that to
374
- // the reader's cache hygiene, notice it and fetch once past the cache.
375
- if (!engineBridgeComplete(Module)) {
376
- console.warn('[rsdkv4] the engine that loaded is missing bridges this SDK needs — refetching past the HTTP cache');
377
- Module = await instantiate(String(Date.now()));
378
- if (!engineBridgeComplete(Module)) {
379
- console.error('[rsdkv4] still missing engine bridges after a cache-busting reload — rsdkv4.wasm is genuinely older than this SDK; rebuild it (`make build-wasm`)');
380
- }
381
- }
382
- // Mount the working dir first, so we can see what's already persisted (OPFS)
383
- // before deciding whether to pull assets.
384
- const { persistent } = mountWorkingDir(Module, config.persist);
385
- const storageNamespace = normalizeStorageNamespace(config.storageNamespace);
386
- const workDir = `${WORK_ROOT}/${storageNamespace}`;
387
- ensureDir(Module, workDir);
388
- const dataPath = `${workDir}/Data.rsdk`;
389
- const settingsPath = `${workDir}/settings.ini`;
390
- // Data.rsdk — precedence: explicit asset > already persisted (skip fetch) > lazy provider.
391
- let dataBytes = toUint8(assets?.data);
392
- if (!dataBytes) {
393
- if (fileExists(Module, dataPath)) {
394
- console.log(`[rsdkv4] Data.rsdk already in persistent storage (${storageNamespace}) — skipping fetch`);
395
- }
396
- else if (config.dataProvider) {
397
- dataBytes = toUint8(await config.dataProvider());
398
- }
399
758
  }
400
- if (dataBytes) {
401
- Module.FS.writeFile(dataPath, dataBytes);
759
+ /** The engine's stage list and loader. Available once the engine is up. */
760
+ get devMenu() {
761
+ const Module = this.#requireModule();
762
+ return {
763
+ getStageList: () => parseJson(Module.web_devmenu_get_stage_list, [], 'web_devmenu_get_stage_list'),
764
+ loadStage: (categoryIdx, stageIdx) => {
765
+ Module.web_devmenu_load_stage?.(categoryIdx | 0, stageIdx | 0);
766
+ },
767
+ setPaused: (paused) => {
768
+ if (paused)
769
+ this.pause('host');
770
+ else
771
+ this.resume(this.#pauseOwner ?? 'host');
772
+ },
773
+ canOpen: typeof Module.web_devmenu_open === 'function',
774
+ open: (screen = 'main', category = 0) => {
775
+ if (typeof Module.web_devmenu_open !== 'function') {
776
+ console.warn('[rsdkv4] this rsdkv4.wasm has no web_devmenu_open — rebuild it (`make build-wasm`), or read devMenu.canOpen before offering the button');
777
+ return false;
778
+ }
779
+ const index = DEV_MENU_SCREENS.indexOf(screen);
780
+ if (index < 0) {
781
+ console.warn(`[rsdkv4] "${screen}" is not a dev menu screen — one of ${DEV_MENU_SCREENS.join(', ')}`);
782
+ return false;
783
+ }
784
+ Module.web_devmenu_open(index, category | 0);
785
+ return true;
786
+ },
787
+ };
402
788
  }
403
- else if (!fileExists(Module, dataPath)) {
404
- throw new Error('rsdkv4: no Data.rsdk — provide assets.data, a dataProvider, or a persisted copy (OPFS)');
789
+ // ---- Bringing the engine up ----
790
+ #requireModule() {
791
+ if (!this.#module) {
792
+ throw new Error('rsdkv4: the engine is not running — await start() before reaching for it');
793
+ }
794
+ return this.#module;
405
795
  }
406
- // settings.ini — explicit asset > persisted > lazy provider > generated from options.
407
- let settingsBytes = toUint8(assets?.settings);
408
- if (!settingsBytes && !fileExists(Module, settingsPath) && config.settingsProvider) {
409
- settingsBytes = toUint8(await config.settingsProvider());
796
+ #ensureBooted() {
797
+ if (this.#destroyed) {
798
+ throw new Error('rsdkv4: this engine has been destroyed and cannot be restarted in process — build a new Rsdkv4SDK');
799
+ }
800
+ // A boot that failed must not poison the next attempt. The common failure is a
801
+ // host that started before handing over `assets.data` — it fixes that and calls
802
+ // `start()` again, and caching the rejection would tell it "no Data.rsdk" forever.
803
+ this.#booting ??= this.#boot().catch((error) => {
804
+ this.#booting = null;
805
+ this.#module = null;
806
+ throw error;
807
+ });
808
+ return this.#booting;
410
809
  }
411
- if (settingsBytes) {
412
- Module.FS.writeFile(settingsPath, settingsBytes);
810
+ async #boot() {
811
+ const canvas = this.#canvas;
812
+ if (!canvas) {
813
+ throw new Error('rsdkv4: mount(target) before start() — the engine draws into a canvas');
814
+ }
815
+ const config = valuesOf(await this.config());
816
+ const assets = valuesOf(await this.assets());
817
+ const storage = valuesOf(await this.storage());
818
+ const Module = await this.#instantiate(canvas, config);
819
+ this.#module = Module;
820
+ // Mount the working dir first, so we can see what is already persisted (OPFS)
821
+ // before deciding whether to pull assets.
822
+ this.#persistent = mountWorkingDir(Module, storage.persist).persistent;
823
+ this.#namespace = normalizeStorageNamespace(storage.namespace);
824
+ this.#workDir = `${WORK_ROOT}/${this.#namespace}`;
825
+ this.#mirrorSaves = storage.persist !== null;
826
+ ensureDir(Module, this.#workDir);
827
+ await this.#writeAssets(Module, assets, config);
828
+ // `persist: null` means the host wants nothing kept, so honour it; otherwise pull
829
+ // any previous SData.bin in before the engine reads it.
830
+ if (this.#mirrorSaves)
831
+ await this.#restoreSaveData(Module);
832
+ Module.FS.chdir(this.#workDir);
833
+ // Run main() from the working dir. simulate_infinite_loop schedules the rAF loop
834
+ // and returns via a benign unwind Emscripten swallows.
835
+ Module.callMain(['UsingCWD']);
836
+ // callMain only *schedules* the loop: Engine::Init runs on the first frame, and
837
+ // until it has, there is no game config, stage list or saveRAM to read. Waiting
838
+ // here means the bridges answer the moment start() resolves.
839
+ await waitForEngine(Module);
840
+ this.#focusCanvas();
841
+ // The engine writes SData.bin at its own pace (new game, checkpoint, options
842
+ // change), so copy it out on a slow timer as well as on the way down.
843
+ //
844
+ // The timer alone loses saves, and by a wide margin: `destroy()` is the only
845
+ // other flush, and a browser tab that is closed or reloaded never calls it. A
846
+ // player who finished an act and hit reload inside the next ten seconds had the
847
+ // save written by the engine, held in the wasm heap, and dropped on the floor —
848
+ // which reads as "my progress did not stick" and looks exactly like a save
849
+ // system that does not work.
850
+ //
851
+ // `visibilitychange` → hidden is the flush that actually fires: it covers the
852
+ // tab being switched away from, the window being minimised, the phone going to
853
+ // the home screen, and — unlike `beforeunload`, which browsers increasingly
854
+ // ignore — navigating away and closing. `pagehide` backs it up for the bfcache
855
+ // path. Cheap to over-fire: the mirror compares bytes and returns without
856
+ // touching OPFS when nothing moved.
857
+ if (this.#mirrorSaves) {
858
+ this.#saveTimer = setInterval(() => void this.#mirrorSaveData(), 10_000);
859
+ document.addEventListener('visibilitychange', this.#onVisibilityChange);
860
+ window.addEventListener('pagehide', this.#onPageHide);
861
+ }
862
+ return Module;
413
863
  }
414
- else if (!fileExists(Module, settingsPath)) {
415
- Module.FS.writeFile(settingsPath, new TextEncoder().encode(buildSettingsIni(options)));
864
+ async #instantiate(canvas, config) {
865
+ const jsUrl = config.jsUrl ?? new URL('./rsdkv4.js', import.meta.url).href;
866
+ const wasmUrl = config.wasmUrl ?? new URL('./rsdkv4.wasm', import.meta.url).href;
867
+ const load = async (bust) => {
868
+ const withBust = (url) => {
869
+ if (!bust)
870
+ return url;
871
+ const parsed = new URL(url, location.href);
872
+ parsed.searchParams.set('_', bust);
873
+ return parsed.href;
874
+ };
875
+ const mod = await import(/* @vite-ignore */ withBust(jsUrl));
876
+ return mod.default({
877
+ canvas,
878
+ noInitialRun: true, // built with -sINVOKE_RUN=0; we mount data before main()
879
+ locateFile: (path) => (path.endsWith('.wasm') ? withBust(wasmUrl) : path),
880
+ print: (...a) => console.log('[rsdkv4]', ...a),
881
+ printErr: (...a) => console.error('[rsdkv4]', ...a),
882
+ onAbort: (reason) => {
883
+ this.emit('error', new Error(`rsdkv4 aborted: ${reason}`));
884
+ },
885
+ });
886
+ };
887
+ let Module = await load();
888
+ // Self-heal a stale engine.
889
+ //
890
+ // rsdkv4.js/.wasm are big and served by most static hosts with nothing but
891
+ // Last-Modified, so a browser will happily keep a build for hours. The symptom is
892
+ // nasty: fresh SDK code against an old engine, where the bridges this SDK calls
893
+ // simply don't exist and every getter quietly answers "empty" — no save slots, no
894
+ // characters, an empty pause menu. Rather than leave that to the reader's cache
895
+ // hygiene, notice it and fetch once past the cache.
896
+ if (!engineBridgeComplete(Module)) {
897
+ console.warn('[rsdkv4] the engine that loaded is missing bridges this SDK needs — refetching past the HTTP cache');
898
+ Module = await load(String(Date.now()));
899
+ if (!engineBridgeComplete(Module)) {
900
+ console.error('[rsdkv4] still missing engine bridges after a cache-busting reload — rsdkv4.wasm is genuinely older than this SDK; rebuild it (`make build-wasm`)');
901
+ }
902
+ }
903
+ return Module;
416
904
  }
417
- // else: keep the persisted settings.ini
418
- // Save data. `persist: null` means the host wants nothing kept, so honour it;
419
- // otherwise pull any previous SData.bin in before the engine reads it.
420
- const mirrorSaves = config.persist !== null;
421
- if (mirrorSaves)
422
- await restoreSaveData(Module, storageNamespace, workDir);
423
- Module.FS.chdir(workDir);
424
- let pauseOwner = null;
425
- const applyPaused = (paused) => {
426
- if (typeof Module.web_devmenu_set_paused === 'function')
427
- Module.web_devmenu_set_paused(paused);
428
- if (typeof Module.web_audio_set_paused === 'function')
429
- Module.web_audio_set_paused(paused);
430
- };
431
- const pauseWith = (owner) => {
432
- if (pauseOwner !== null)
433
- return; // already paused — whoever got there first keeps the claim
434
- pauseOwner = owner;
435
- applyPaused(true);
436
- // A pause is a natural checkpoint for the save mirror.
437
- if (mirrorSaves)
438
- void persistSaveData(Module, storageNamespace, workDir);
439
- };
440
- const resumeFrom = (owner) => {
441
- if (pauseOwner !== owner)
442
- return; // someone else's pause — leave it alone
443
- pauseOwner = null;
444
- applyPaused(false);
445
- };
446
- /** Legacy shape kept for `devMenu.setPaused` — always acts as the host. */
447
- const setPaused = (paused) => {
448
- if (paused)
449
- pauseWith('host');
450
- else
451
- resumeFrom(pauseOwner ?? 'host');
452
- };
453
- const stopAudio = () => {
905
+ /** Data.rsdk and settings.ini into the working dir. See {@link Rsdkv4Assets}. */
906
+ async #writeAssets(Module, assets, config) {
907
+ const dataPath = `${this.#workDir}/Data.rsdk`;
908
+ const settingsPath = `${this.#workDir}/settings.ini`;
909
+ // The pack — the expensive one, so a persisted copy short-circuits the thunk.
910
+ if (fileExists(Module, dataPath)) {
911
+ const explicit = typeof assets.data === 'function' ? null : await resolveAsset(assets.data);
912
+ if (explicit)
913
+ Module.FS.writeFile(dataPath, explicit);
914
+ else {
915
+ console.log(`[rsdkv4] Data.rsdk already in persistent storage (${this.#namespace}) — skipping fetch`);
916
+ }
917
+ }
918
+ else {
919
+ const bytes = await resolveAsset(assets.data);
920
+ if (!bytes) {
921
+ throw new Error('rsdkv4: no Data.rsdk — hand one over as assets.data (bytes or a thunk), or point storage.namespace at a persisted copy');
922
+ }
923
+ Module.FS.writeFile(dataPath, bytes);
924
+ }
925
+ // The settings — cheap, and regenerated every boot so `config` is never shadowed
926
+ // by a file an earlier session left behind. The engine never writes it back (its
927
+ // writeSettings() is #ifndef __EMSCRIPTEN__), so there is nothing to lose.
928
+ const settings = await resolveAsset(assets.settings);
929
+ Module.FS.writeFile(settingsPath, settings ?? new TextEncoder().encode(buildSettingsIni(config)));
930
+ }
931
+ // ---- The save mirror ----
932
+ /**
933
+ * Pull a previously mirrored SData.bin into the wasm before the engine reads it.
934
+ *
935
+ * The working dir is in-memory whenever the OPFS *mount* is unavailable (which is
936
+ * every build without Asyncify/JSPI — see {@link mountWorkingDir}), so SData.bin
937
+ * would die with the page and every slot would read "NEW GAME" forever. The host-side
938
+ * OPFS API has no such limitation, so the SDK copies the file in here and back out as
939
+ * it changes.
940
+ */
941
+ async #restoreSaveData(Module) {
942
+ const dir = await opfsNamespaceDir(this.#namespace, false);
943
+ if (!dir)
944
+ return;
454
945
  try {
455
- Module.web_audio_stop?.();
946
+ const file = await (await dir.getFileHandle(SAVE_FILE)).getFile();
947
+ if (file.size > 0) {
948
+ const bytes = new Uint8Array(await file.arrayBuffer());
949
+ Module.FS.writeFile(`${this.#workDir}/${SAVE_FILE}`, bytes);
950
+ this.#lastSave = bytes;
951
+ }
456
952
  }
457
953
  catch {
458
- /* engine already down */
954
+ /* no save yet */
459
955
  }
460
- };
956
+ }
461
957
  /**
462
- * Call a `web_*` embind getter and parse its JSON.
958
+ * Copy SData.bin out of the wasm into `<namespace>/`, and announce it if it moved.
463
959
  *
464
- * A missing function means the loaded rsdkv4.wasm predates that bridge — the
465
- * usual cause is a browser serving a cached build. Say so out loud: silently
466
- * returning an empty list makes a stale artifact look like an empty save file
467
- * or a game with no playable characters.
960
+ * The division of labour the session settled on: the SDK owns getting the bytes out
961
+ * of the wasm and into the namespace both sides already agree on; a host owns
962
+ * reading, parsing and painting them. `saves` is what tells it to look again — which
963
+ * is why it fires on a change and not on every check.
468
964
  */
469
- const parseJson = (fn, fallback, what) => {
470
- if (typeof fn !== 'function') {
471
- console.error(`[rsdkv4] ${what} is missing from this build of rsdkv4.wasm — the page is probably running a cached copy. Hard-reload (or clear the site's storage) after rebuilding.`);
472
- return fallback;
473
- }
965
+ async #mirrorSaveData() {
966
+ const Module = this.#module;
967
+ if (!Module)
968
+ return;
969
+ // Explicitly over an ArrayBuffer, not the default ArrayBufferLike: OPFS's
970
+ // `write()` will not take a view that might be backed by a SharedArrayBuffer,
971
+ // and on a cross-origin isolated page that is not a hypothetical distinction.
972
+ let bytes;
474
973
  try {
475
- return JSON.parse(fn());
974
+ bytes = Module.FS.readFile(`${this.#workDir}/${SAVE_FILE}`);
476
975
  }
477
- catch (e) {
478
- console.error(`[rsdkv4] ${what} failed`, e);
479
- return fallback;
976
+ catch {
977
+ return; // the engine hasn't written one
480
978
  }
481
- };
482
- const game = {
483
- type: () => (typeof Module.web_game_type === 'function' ? Module.web_game_type() : 0),
484
- players: () => parseJson(Module.web_get_players, [], 'web_get_players'),
485
- saveSlots: () => parseJson(Module.web_get_save_slots, [], 'web_get_save_slots'),
486
- options: () => parseJson(Module.web_get_game_options, [], 'web_get_game_options'),
487
- setOption(key, value) {
488
- Module.web_set_game_option?.(String(key), value | 0);
489
- },
490
- deleteSave(slot) {
491
- Module.web_delete_save?.(slot | 0);
492
- },
493
- start(slot, player = 0) {
494
- Module.web_start_game?.(slot == null ? -1 : slot | 0, player | 0);
495
- // The overlay that drove this is done with the engine — hand it back.
496
- pauseOwner = null;
497
- applyPaused(false);
498
- },
499
- };
500
- const devMenu = {
501
- getStageList() {
502
- if (typeof Module.web_devmenu_get_stage_list !== 'function')
503
- return [];
979
+ if (sameBytes(bytes, this.#lastSave))
980
+ return;
981
+ const dir = await opfsNamespaceDir(this.#namespace, true);
982
+ if (dir) {
504
983
  try {
505
- return JSON.parse(Module.web_devmenu_get_stage_list());
984
+ const handle = await dir.getFileHandle(SAVE_FILE, { create: true });
985
+ const writable = await handle.createWritable();
986
+ await writable.write(bytes);
987
+ await writable.close();
506
988
  }
507
989
  catch (e) {
508
- console.error('[rsdkv4] getStageList failed', e);
509
- return [];
990
+ console.warn('[rsdkv4] could not persist save data', e);
991
+ return;
510
992
  }
511
- },
512
- loadStage(categoryIdx, stageIdx) {
513
- if (typeof Module.web_devmenu_load_stage === 'function') {
514
- Module.web_devmenu_load_stage(categoryIdx | 0, stageIdx | 0);
515
- }
516
- },
517
- setPaused,
993
+ }
994
+ this.#lastSave = bytes;
995
+ this.emit('saves', this.game.saveSlots());
996
+ }
997
+ // ---- The render target ----
998
+ /**
999
+ * Flush the save when the page is being put away. See where these are registered
1000
+ * for why the ten-second timer is not enough on its own.
1001
+ */
1002
+ #onVisibilityChange = () => {
1003
+ if (document.visibilityState === 'hidden' && this.#mirrorSaves)
1004
+ void this.#mirrorSaveData();
518
1005
  };
519
- // Run main() from the OPFS/WASMFS working dir. simulate_infinite_loop schedules
520
- // the rAF loop and returns via a benign unwind Emscripten swallows.
521
- Module.callMain(['UsingCWD']);
522
- // callMain only *schedules* the loop: Engine::Init runs on the first frame, and
523
- // until it has, there is no game config, stage list or saveRAM to read. Waiting
524
- // here means a host can call devMenu/game getters the moment load() resolves.
525
- await waitForEngine(Module);
526
- focusCanvas();
527
- // The engine writes SData.bin at its own pace (new game, checkpoint, options
528
- // change), so copy it out on a slow timer as well as on the way down.
529
- const saveTimer = mirrorSaves
530
- ? setInterval(() => void persistSaveData(Module, storageNamespace, workDir), 10_000)
531
- : null;
532
- emit({ type: 'ready' });
533
- return {
534
- start() { },
535
- /** `owner` lets a pause overlay avoid resuming a game something else paused. */
536
- pause(owner = 'host') { pauseWith(owner); },
537
- resume(owner = 'host') { resumeFrom(owner); },
538
- reset() {
539
- throw new Error('rsdkv4: reset() is not supported — destroy() and load() again');
540
- },
541
- setInput(preset) {
542
- if (typeof window !== 'undefined') {
543
- window.__gamepadKeyMap = preset ?? manifest.input;
544
- }
545
- },
546
- destroy() {
547
- if (saveTimer !== null)
548
- clearInterval(saveTimer);
549
- // Last chance to keep whatever the player earned this session.
550
- if (mirrorSaves)
551
- void persistSaveData(Module, storageNamespace, workDir);
552
- try {
553
- Module.pauseMainLoop?.();
554
- }
555
- catch { /* noop */ }
556
- try {
557
- setPaused(true);
558
- }
559
- catch { /* noop */ }
560
- // Freezing the loop does not silence the audio callback — without this the
561
- // music kept playing after the host went back to its launcher.
562
- stopAudio();
563
- canvas.removeEventListener('contextmenu', swallowContextMenu);
564
- canvas.removeEventListener('pointerdown', onPointerDown);
565
- document.removeEventListener('fullscreenchange', onFullscreenChange);
566
- window.removeEventListener('resize', onResize);
567
- boxObserver?.disconnect();
568
- if (ownsCanvas)
569
- canvas.remove();
570
- },
571
- devMenu,
572
- game,
573
- persistent,
574
- storageNamespace,
575
- purgeStorage() {
576
- return {
577
- data: deleteFileIfExists(Module, dataPath),
578
- settings: deleteFileIfExists(Module, settingsPath),
579
- };
580
- },
1006
+ #onPageHide = () => {
1007
+ if (this.#mirrorSaves)
1008
+ void this.#mirrorSaveData();
1009
+ };
1010
+ #swallowContextMenu = (event) => event.preventDefault();
1011
+ #onPointerDown = () => this.#focusCanvas();
1012
+ #onKeyDown = (event) => {
1013
+ // Only the browser's own default action is suppressed — the arrows and Space
1014
+ // scroll the page, Tab walks the focus out of the canvas. The event is left to
1015
+ // propagate: SDL's own handler sits on `window` in the bubble phase, so calling
1016
+ // stopPropagation() here would be the engine's whole keyboard.
1017
+ if (ENGINE_KEYS.has(event.code) || ENGINE_KEYS.has(event.key))
1018
+ event.preventDefault();
581
1019
  };
1020
+ #onResize = () => this.#fitPicture();
1021
+ #onFullscreenChange = () => {
1022
+ this.#fitPicture();
1023
+ requestAnimationFrame(() => this.#fitPicture());
1024
+ };
1025
+ #focusCanvas() {
1026
+ if (!this.#canvas)
1027
+ return;
1028
+ if (!document.hasFocus())
1029
+ window.focus();
1030
+ this.#canvas.focus({ preventScroll: true });
1031
+ }
1032
+ #fitPicture() {
1033
+ if (!this.#ownsCanvas || !this.#canvas || !this.#box)
1034
+ return;
1035
+ const box = this.#box.getBoundingClientRect();
1036
+ if (box.width < 1 || box.height < 1)
1037
+ return;
1038
+ const { baseWidth, baseHeight } = RSDKV4_VIDEO;
1039
+ const scale = Math.min(box.width / baseWidth, box.height / baseHeight);
1040
+ this.#canvas.style.width = `${Math.round(baseWidth * scale)}px`;
1041
+ this.#canvas.style.height = `${Math.round(baseHeight * scale)}px`;
1042
+ }
1043
+ /**
1044
+ * Move the picture we already have. SDL keeps its canvas; only the parent changes.
1045
+ *
1046
+ * Pointing a second `mount()` at a *different* `<canvas>` is the one case this
1047
+ * refuses: SDL2 resolved `#canvas` once, at instantiation, so swapping the element
1048
+ * would leave the engine drawing into an orphan while the host watched a blank one.
1049
+ */
1050
+ #reparent(target) {
1051
+ const canvas = this.#canvas;
1052
+ if (!canvas || target === canvas || canvas.parentElement === target)
1053
+ return;
1054
+ if (target instanceof HTMLCanvasElement) {
1055
+ console.warn('[rsdkv4] the canvas SDL draws into cannot be swapped after mounting — ignoring this mount()');
1056
+ return;
1057
+ }
1058
+ target.appendChild(canvas);
1059
+ if (this.#box)
1060
+ this.#boxObserver?.unobserve(this.#box);
1061
+ this.#box = this.#ownsCanvas ? target : canvas;
1062
+ if (this.#box)
1063
+ this.#boxObserver?.observe(this.#box);
1064
+ this.#fitPicture();
1065
+ }
1066
+ #teardownCanvas() {
1067
+ const canvas = this.#canvas;
1068
+ if (canvas) {
1069
+ canvas.removeEventListener('contextmenu', this.#swallowContextMenu);
1070
+ canvas.removeEventListener('pointerdown', this.#onPointerDown);
1071
+ canvas.removeEventListener('keydown', this.#onKeyDown);
1072
+ if (this.#ownsCanvas)
1073
+ canvas.remove();
1074
+ }
1075
+ document.removeEventListener('fullscreenchange', this.#onFullscreenChange);
1076
+ document.removeEventListener('visibilitychange', this.#onVisibilityChange);
1077
+ window.removeEventListener('pagehide', this.#onPageHide);
1078
+ window.removeEventListener('resize', this.#onResize);
1079
+ this.#boxObserver?.disconnect();
1080
+ this.#boxObserver = null;
1081
+ this.#canvas = null;
1082
+ this.#box = null;
1083
+ }
1084
+ // ---- Misc ----
1085
+ #applyPaused(paused) {
1086
+ const Module = this.#module;
1087
+ if (!Module)
1088
+ return;
1089
+ if (typeof Module.web_devmenu_set_paused === 'function')
1090
+ Module.web_devmenu_set_paused(paused);
1091
+ if (typeof Module.web_audio_set_paused === 'function')
1092
+ Module.web_audio_set_paused(paused);
1093
+ }
1094
+ /** A character name, as the pack spells it, to the index the engine wants. */
1095
+ #playerIndex(player) {
1096
+ if (player === undefined)
1097
+ return 0;
1098
+ const players = this.game.players();
1099
+ const index = players.findIndex((name) => name.toLowerCase() === player.toLowerCase());
1100
+ if (index < 0) {
1101
+ throw new Error(`rsdkv4: "${player}" is not a playable character in this pack — try one of ${players.join(', ') || '(none reported)'}`);
1102
+ }
1103
+ return index;
1104
+ }
1105
+ }
1106
+ // ---------------------------------------------------------------- the factory
1107
+ /**
1108
+ * The factory a host imports: identity on the constructor, one live engine per
1109
+ * instance, and a `start()` that brings the wasm up and opens a session.
1110
+ *
1111
+ * Everything on it is the contract's — see the module doc for the shape of a call.
1112
+ */
1113
+ export class Rsdkv4SDK extends EngineSDKBase {
1114
+ static id = 'rsdkv4';
1115
+ /** Kept in step with package.json by `node scripts/sync-version.mjs`. */
1116
+ static version = '0.2.1';
1117
+ static name = 'Retro Software Development Kit v4';
1118
+ static description = 'RSDKv4 compiled to WebAssembly via Emscripten. One game-agnostic WASM binary runs both Sonic 1 and Sonic 2 — the difference is only which Data.rsdk the host supplies at runtime. Ships none of the game: the player provides their own Data.rsdk, and it never leaves their browser.';
1119
+ createPlay(init, events) {
1120
+ return new Rsdkv4Play(init, events);
1121
+ }
1122
+ start(options) {
1123
+ return super.start(options);
1124
+ }
1125
+ }
1126
+ /**
1127
+ * The line that puts the contract in CI: if this file stops satisfying
1128
+ * `EngineSDKConstructor`, `make typecheck` says so.
1129
+ */
1130
+ export const RSDKV4_ENGINE = Rsdkv4SDK;
1131
+ export default Rsdkv4SDK;
1132
+ // ---------------------------------------------------------------- module-private helpers
1133
+ /**
1134
+ * Call a `web_*` embind getter and parse its JSON.
1135
+ *
1136
+ * A missing function means the loaded rsdkv4.wasm predates that bridge — the usual
1137
+ * cause is a browser serving a cached build. Say so out loud: silently returning an
1138
+ * empty list makes a stale artifact look like an empty save file or a game with no
1139
+ * playable characters.
1140
+ */
1141
+ function parseJson(fn, fallback, what) {
1142
+ if (typeof fn !== 'function') {
1143
+ console.error(`[rsdkv4] ${what} is missing from this build of rsdkv4.wasm — the page is probably running a cached copy. Hard-reload (or clear the site's storage) after rebuilding.`);
1144
+ return fallback;
1145
+ }
1146
+ try {
1147
+ return JSON.parse(fn());
1148
+ }
1149
+ catch (e) {
1150
+ console.error(`[rsdkv4] ${what} failed`, e);
1151
+ return fallback;
1152
+ }
1153
+ }
1154
+ /**
1155
+ * A claim, checked once — exactly the fourteen buttons, with missing and unknown both
1156
+ * fatal.
1157
+ *
1158
+ * Strict because the two failure modes are silent and asymmetric. A **missing** key
1159
+ * reads `false` on every tick: a dead button, no error anywhere, and easy to blame on
1160
+ * the engine. An **unknown** key is a button the host writes to that nothing ever
1161
+ * reads. The first sketch of this API lost `right` to a duplicated `up` — a duplicate
1162
+ * key in an object literal is legal JavaScript and surfaces only as an absent one.
1163
+ *
1164
+ * `Object.keys`, not `in`: a reactive store's `has` trap can answer `true` for a key
1165
+ * that has been deleted (jq79 does exactly that, deliberately, so a template
1166
+ * expression reading a deleted name takes its else branch instead of dying), and
1167
+ * `ownKeys` has no such licence.
1168
+ */
1169
+ function validateButtons(buttons) {
1170
+ if (typeof buttons !== 'object' || buttons === null) {
1171
+ throw new Error(`rsdkv4: options({ buttons }) takes an object of the 14 buttons or null to release, not ${typeof buttons}`);
1172
+ }
1173
+ const keys = new Set(Object.keys(buttons));
1174
+ const missing = RSDKV4_BUTTONS.filter((button) => !keys.has(button));
1175
+ const unknown = [...keys].filter((key) => !RSDKV4_BUTTONS.includes(key));
1176
+ if (missing.length || unknown.length) {
1177
+ const faults = [
1178
+ missing.length && `missing ${missing.join(', ')}`,
1179
+ unknown.length && `unknown ${unknown.join(', ')}`,
1180
+ ].filter(Boolean);
1181
+ throw new Error(`rsdkv4: the buttons object must carry exactly the 14 buttons (${RSDKV4_BUTTONS.join(', ')}) — ${faults.join('; ')}. A missing button is dead, not neutral, so this is an error rather than a default.`);
1182
+ }
1183
+ }
1184
+ /** The engine's two-level stage list as flat, writable {@link Rsdkv4Stage} values. */
1185
+ function flattenStages(categories) {
1186
+ const out = [];
1187
+ categories.forEach((category, categoryIdx) => {
1188
+ category.stages.forEach((stage, sceneIdx) => {
1189
+ out.push({ category: categoryIdx, scene: sceneIdx, name: `${category.name} · ${stage.name}` });
1190
+ });
1191
+ });
1192
+ return out;
1193
+ }
1194
+ /** A GAME OPTIONS row as a host reads it: a boolean, or the label of the chosen entry. */
1195
+ function fromEngineOption(row) {
1196
+ if (row.type === 'boolean')
1197
+ return row.value !== 0;
1198
+ return row.values?.[row.value] ?? row.value;
1199
+ }
1200
+ /** …and back to the number the engine stores. */
1201
+ function toEngineOption(row, value) {
1202
+ if (typeof value === 'boolean')
1203
+ return value ? 1 : 0;
1204
+ if (typeof value === 'number')
1205
+ return value | 0;
1206
+ if (typeof value === 'string') {
1207
+ const index = (row.values ?? []).findIndex((choice) => choice.toLowerCase() === value.toLowerCase());
1208
+ if (index >= 0)
1209
+ return index;
1210
+ }
1211
+ throw new Error(`rsdkv4: ${JSON.stringify(value)} is not a value "${row.key}" accepts — try one of ${row.type === 'boolean' ? 'true, false' : (row.values ?? []).join(', ')}`);
582
1212
  }
583
- export default { manifest, load };
584
1213
  //# sourceMappingURL=rsdkv4.sdk.js.map