@wasm-gaming/rsdkv4-wasm 0.1.2 → 0.1.5

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.
@@ -24,6 +24,12 @@ export interface Rsdkv4Options {
24
24
  engineDebugMode?: boolean;
25
25
  /** VSync the engine's SDL window. */
26
26
  vsync?: boolean;
27
+ /**
28
+ * Skip RSDKv4's in-canvas Start Menu (save select / character select / game
29
+ * options). Set this when the host draws those screens itself from
30
+ * `instance.game` — otherwise the engine's own menu runs underneath.
31
+ */
32
+ skipStartMenu?: boolean;
27
33
  /** Boot directly into a stage: 0-based stage-list category. */
28
34
  startingCategory?: number;
29
35
  /** Boot directly into a stage: 0-based scene within the category. */
@@ -32,13 +38,21 @@ export interface Rsdkv4Options {
32
38
  startingPlayer?: number;
33
39
  }
34
40
 
41
+ /**
42
+ * RSDKv4 reads 255 as "not set" for the starting stage/player (see Userdata.cpp:
43
+ * anything else — **including 0** — makes Engine::Init skip the start-menu flow
44
+ * and force InitStartingStage). So "boot normally" has to be 255, not 0.
45
+ */
46
+ export const RSDKV4_UNSET = 255;
47
+
35
48
  export const DEFAULT_RSDKV4_OPTIONS: Required<Rsdkv4Options> = {
36
49
  devMenu: false,
37
50
  engineDebugMode: true,
38
51
  vsync: true,
39
- startingCategory: 0,
40
- startingScene: 0,
41
- startingPlayer: 0,
52
+ skipStartMenu: false,
53
+ startingCategory: RSDKV4_UNSET,
54
+ startingScene: RSDKV4_UNSET,
55
+ startingPlayer: RSDKV4_UNSET,
42
56
  };
43
57
 
44
58
  export const RSDKV4_OPTIONS_SCHEMA: JSONSchema = {
@@ -57,8 +71,29 @@ export const RSDKV4_OPTIONS_SCHEMA: JSONSchema = {
57
71
  description: 'Enables the web devmenu embind bridge (stage list / warp / pause).',
58
72
  },
59
73
  vsync: { type: 'boolean', default: true },
60
- startingCategory: { type: 'integer', minimum: 0, default: 0 },
61
- startingScene: { type: 'integer', minimum: 0, default: 0 },
62
- startingPlayer: { type: 'integer', minimum: 0, default: 0 },
74
+ skipStartMenu: {
75
+ type: 'boolean',
76
+ default: false,
77
+ description:
78
+ "Skip the engine's own save/character/options screens — set it when the host renders them from instance.game.",
79
+ },
80
+ startingCategory: {
81
+ type: 'integer',
82
+ minimum: 0,
83
+ default: RSDKV4_UNSET,
84
+ description: '0-based stage-list category; 255 = unset (normal boot flow).',
85
+ },
86
+ startingScene: {
87
+ type: 'integer',
88
+ minimum: 0,
89
+ default: RSDKV4_UNSET,
90
+ description: '0-based scene in the category; 255 = unset (normal boot flow).',
91
+ },
92
+ startingPlayer: {
93
+ type: 'integer',
94
+ minimum: 0,
95
+ default: RSDKV4_UNSET,
96
+ description: 'Player/character index; 255 = unset (normal boot flow).',
97
+ },
63
98
  },
64
99
  };
package/src/rsdkv4.sdk.ts CHANGED
@@ -9,12 +9,14 @@
9
9
  //
10
10
  // Filesystem: built with -sWASMFS (the modern Emscripten filesystem, not MEMFS).
11
11
  // The game working dir `/data` is mounted on **OPFS** for persistence when the
12
- // page is cross-origin isolated; otherwise it falls back to the WASMFS in-memory
13
- // backend (transient, but the engine still boots). The backend is selectable via
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
14
16
  // `config.persist` (see mountWorkingDir), and when the dir is OPFS-backed the SDK
15
17
  // reuses an already-persisted Data.rsdk/settings.ini instead of re-fetching.
16
18
 
17
- import type { EngineConfig, EngineInstance, AssetData } from '@wasm-gaming/engine-specs';
19
+ import type { EngineConfig, EngineInstance, EngineEvent, AssetData, InputPreset, KeyMap } from '@wasm-gaming/engine-specs';
18
20
  import { manifest } from './rsdkv4.manifest.js';
19
21
  import { DEFAULT_RSDKV4_OPTIONS, type Rsdkv4Options } from './rsdkv4.options.js';
20
22
 
@@ -23,7 +25,19 @@ export { manifest };
23
25
  const WORK_ROOT = '/data';
24
26
  const DEFAULT_STORAGE_NAMESPACE = 'default';
25
27
 
26
- /** Serialize engine options into RSDKv4's settings.ini format. */
28
+ /**
29
+ * Emscripten's SDL2 port locates the canvas via `document.querySelector('#canvas')`,
30
+ * so the element the game draws into has to carry this id.
31
+ */
32
+ const CANVAS_ID = 'canvas';
33
+
34
+ /**
35
+ * Serialize engine options into RSDKv4's settings.ini format.
36
+ *
37
+ * Note the Starting* trio defaults to 255 ("unset") — writing 0 is not neutral,
38
+ * it tells the engine to skip its start-menu flow and force that stage. See
39
+ * DEFAULT_RSDKV4_OPTIONS.
40
+ */
27
41
  function buildSettingsIni(options: Rsdkv4Options = {}): string {
28
42
  const o = { ...DEFAULT_RSDKV4_OPTIONS, ...options };
29
43
  return [
@@ -34,6 +48,9 @@ function buildSettingsIni(options: Rsdkv4Options = {}): string {
34
48
  `StartingScene=${o.startingScene | 0}`,
35
49
  `StartingPlayer=${o.startingPlayer | 0}`,
36
50
  '',
51
+ '[Game]',
52
+ `SkipStartMenu=${o.skipStartMenu ? 'true' : 'false'}`,
53
+ '',
37
54
  '[Window]',
38
55
  `VSync=${o.vsync ? 'true' : 'false'}`,
39
56
  '',
@@ -78,6 +95,26 @@ function ensureDir(Module: any, path: string): void {
78
95
  }
79
96
  }
80
97
 
98
+ /**
99
+ * Whether this build can mount OPFS at all, asked of the wasm itself.
100
+ *
101
+ * WASMFS's `wasmfs_create_opfs_backend()` spawns a proxy worker synchronously,
102
+ * which it refuses to do on the main browser thread without Asyncify/JSPI — it
103
+ * *asserts*, and a failed assert calls `abort()`, which tears the module down for
104
+ * good. That is past the point where a try/catch around the call can save us, so
105
+ * we have to know before calling. `web_opfs_supported` (WebFS.cpp) mirrors the
106
+ * same condition; builds predating that helper simply answer "no".
107
+ */
108
+ function opfsMountSupported(Module: any): boolean {
109
+ const probe = Module._web_opfs_supported;
110
+ if (typeof probe !== 'function') return false;
111
+ try {
112
+ return probe() !== 0;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
81
118
  /**
82
119
  * Mount the game working dir, honoring `persist`:
83
120
  * 'opfs' → force an OPFS (persistent) mount; warn + fall back if unavailable.
@@ -85,24 +122,27 @@ function ensureDir(Module: any, path: string): void {
85
122
  * undefined → auto: OPFS when the page is cross-origin isolated, else in-memory.
86
123
  * ('idbfs' isn't supported under WASMFS and is treated as in-memory.)
87
124
  *
88
- * OPFS sync access needs a worker/pthread + cross-origin isolation, so 'auto' only
89
- * attempts it when isolated (the single-threaded build would otherwise trap).
125
+ * Cross-origin isolation is necessary but not sufficient: the mount also needs a
126
+ * build whose OPFS backend can run from where we call it (see opfsMountSupported),
127
+ * so both gates apply before we touch `web_mount_opfs`.
90
128
  * Returns whether the resulting mount is persistent.
91
129
  */
92
130
  function mountWorkingDir(Module: any, persist: EngineConfig['persist']): { persistent: boolean } {
93
131
  const isolated = typeof crossOriginIsolated !== 'undefined' && crossOriginIsolated;
94
- const attemptOpfs = persist === 'opfs' || (persist === undefined && isolated);
132
+ const wantsOpfs = persist === 'opfs' || (persist === undefined && isolated);
95
133
 
96
- if (attemptOpfs && typeof Module.ccall === 'function') {
134
+ if (wantsOpfs && typeof Module.ccall === 'function' && opfsMountSupported(Module)) {
97
135
  try {
98
136
  const rc = Module.ccall('web_mount_opfs', 'number', ['string'], [WORK_ROOT]);
99
137
  if (rc === 0) return { persistent: true };
100
138
  } catch {
101
139
  /* fall through to in-memory */
102
140
  }
103
- if (persist === 'opfs') {
104
- console.warn('[rsdkv4] OPFS requested but unavailable — using in-memory WASMFS');
105
- }
141
+ }
142
+ if (persist === 'opfs') {
143
+ console.warn(
144
+ '[rsdkv4] OPFS requested but unavailable (needs a cross-origin isolated page and an Asyncify/JSPI build) — using in-memory WASMFS',
145
+ );
106
146
  }
107
147
  ensureDir(Module, WORK_ROOT);
108
148
  return { persistent: false };
@@ -118,6 +158,107 @@ function fileExists(Module: any, path: string): boolean {
118
158
  }
119
159
  }
120
160
 
161
+ /**
162
+ * Wait for Engine::Init, which runs on the engine's first frame (main.cpp's
163
+ * main_loop), not inside callMain. Gives up after ~4s rather than hanging the
164
+ * host: everything still works, the first read just comes back empty.
165
+ */
166
+ async function waitForEngine(Module: any, timeoutMs = 4000): Promise<boolean> {
167
+ if (typeof Module.web_engine_ready !== 'function') return false;
168
+ const deadline = Date.now() + timeoutMs;
169
+ while (Date.now() < deadline) {
170
+ if (Module.web_engine_ready()) return true;
171
+ await new Promise((resolve) => requestAnimationFrame(resolve));
172
+ }
173
+ console.warn('[rsdkv4] engine did not report ready within %dms', timeoutMs);
174
+ return false;
175
+ }
176
+
177
+ /**
178
+ * The embind bridges this SDK drives the engine through. All of them ship in the
179
+ * same rsdkv4.wasm, so a module missing any one of them is an artifact older
180
+ * than this SDK rather than a build variant.
181
+ */
182
+ const ENGINE_BRIDGES = [
183
+ 'web_engine_ready',
184
+ 'web_game_type',
185
+ 'web_get_players',
186
+ 'web_get_save_slots',
187
+ 'web_get_game_options',
188
+ 'web_start_game',
189
+ 'web_audio_stop',
190
+ 'web_devmenu_get_stage_list',
191
+ ] as const;
192
+
193
+ function engineBridgeComplete(Module: any): boolean {
194
+ return ENGINE_BRIDGES.every((name) => typeof Module?.[name] === 'function');
195
+ }
196
+
197
+ /** RSDKv4's save file, written next to the game data in the working dir. */
198
+ const SAVE_FILE = 'SData.bin';
199
+
200
+ /**
201
+ * OPFS handle for `<namespace>/`, reached with the plain (async) OPFS API from
202
+ * JS — which, unlike the WASM side's sync access handles, works on the main
203
+ * thread of any page. Returns null when OPFS isn't available at all.
204
+ */
205
+ async function opfsNamespaceDir(
206
+ namespace: string,
207
+ create: boolean,
208
+ ): Promise<FileSystemDirectoryHandle | null> {
209
+ if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return null;
210
+ try {
211
+ let dir = await navigator.storage.getDirectory();
212
+ for (const segment of namespace.split('/').filter(Boolean)) {
213
+ dir = await dir.getDirectoryHandle(segment, { create });
214
+ }
215
+ return dir;
216
+ } catch {
217
+ return null; // missing (create: false), or blocked
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Mirror for the engine's save file.
223
+ *
224
+ * The working dir is in-memory whenever the OPFS *mount* is unavailable (which
225
+ * is every build without Asyncify/JSPI — see mountWorkingDir), so SData.bin
226
+ * would die with the page and every save slot would read "NEW GAME" forever.
227
+ * The host's own OPFS access has no such limitation, so the SDK copies the file
228
+ * in before the engine boots and back out as it changes. Saves therefore
229
+ * survive reloads even while the mounted working dir does not.
230
+ */
231
+ async function restoreSaveData(Module: any, namespace: string, workDir: string): Promise<void> {
232
+ const dir = await opfsNamespaceDir(namespace, false);
233
+ if (!dir) return;
234
+ try {
235
+ const file = await (await dir.getFileHandle(SAVE_FILE)).getFile();
236
+ if (file.size > 0) {
237
+ Module.FS.writeFile(`${workDir}/${SAVE_FILE}`, new Uint8Array(await file.arrayBuffer()));
238
+ }
239
+ } catch {
240
+ /* no save yet */
241
+ }
242
+ }
243
+
244
+ async function persistSaveData(Module: any, namespace: string, workDir: string): Promise<void> {
245
+ let bytes: Uint8Array<ArrayBuffer>;
246
+ try {
247
+ bytes = Module.FS.readFile(`${workDir}/${SAVE_FILE}`);
248
+ } catch {
249
+ return; // the engine hasn't written one
250
+ }
251
+ const dir = await opfsNamespaceDir(namespace, true);
252
+ if (!dir) return;
253
+ try {
254
+ const writable = await (await dir.getFileHandle(SAVE_FILE, { create: true })).createWritable();
255
+ await writable.write(bytes);
256
+ await writable.close();
257
+ } catch (e) {
258
+ console.warn('[rsdkv4] could not persist save data', e);
259
+ }
260
+ }
261
+
121
262
  /** Delete a file if present; returns true when something was removed. */
122
263
  function deleteFileIfExists(Module: any, path: string): boolean {
123
264
  if (!fileExists(Module, path)) return false;
@@ -136,8 +277,68 @@ export interface RsdkDevMenuBridge {
136
277
  setPaused(paused: boolean): void;
137
278
  }
138
279
 
139
- export type Rsdkv4Instance = EngineInstance & {
280
+ /** One of RSDKv4's four save slots, as the engine's own save-select screen sees it. */
281
+ export interface RsdkSaveSlot {
282
+ /** 0-3. */
283
+ slot: number;
284
+ /** No game stored here — starting it means picking a character first. */
285
+ empty: boolean;
286
+ /** Character index into `players()`: 0 Sonic, 1 Tails, 2 Knuckles, 3 Sonic & Tails. */
287
+ character: number;
288
+ lives: number;
289
+ score: number;
290
+ emeralds: number;
291
+ /** Stage list the save resumes into (1 regular, 3 special), or -1 when empty. */
292
+ list: number;
293
+ /** 0-based scene within `list` — index it against `devMenu.getStageList()`. */
294
+ zone: number;
295
+ }
296
+
297
+ /** A row of the engine's GAME OPTIONS screen (differs between Sonic 1 and 2). */
298
+ export interface RsdkGameOption {
299
+ key: string;
300
+ label: string;
301
+ value: number;
302
+ type: 'boolean' | 'enum';
303
+ /** Choice labels, for `type: 'enum'` (item box sets). */
304
+ values?: string[];
305
+ }
306
+
307
+ /**
308
+ * The engine's Start Menu as data, so a host can render save-slot / character /
309
+ * game-option screens itself. Everything here mirrors what RSDKv4's own in-canvas
310
+ * menu does — see WebGame.cpp.
311
+ */
312
+ export interface RsdkGameBridge {
313
+ /** 1 = Sonic 1, 2 = Sonic 2, 0 = unrecognised pack. */
314
+ type(): number;
315
+ /** Playable characters from the pack's GameConfig, in engine order. */
316
+ players(): string[];
317
+ saveSlots(): RsdkSaveSlot[];
318
+ /**
319
+ * Start a game the way the native menu would: a slot with data continues it,
320
+ * an empty slot starts a new game as `player`, and `slot: null` plays without
321
+ * saving. Resumes the engine if the SDK had it paused.
322
+ */
323
+ start(slot: number | null, player?: number): void;
324
+ /** Wipe a slot back to "NEW GAME". */
325
+ deleteSave(slot: number): void;
326
+ options(): RsdkGameOption[];
327
+ /** Set an option; written through to the engine and its save file, as the engine does. */
328
+ setOption(key: string, value: number): void;
329
+ }
330
+
331
+ export type Rsdkv4Instance = Omit<EngineInstance, 'pause' | 'resume'> & {
332
+ /**
333
+ * Pause, on behalf of `owner` (default 'host'). The first caller to pause owns
334
+ * it and only that owner's `resume()` lifts it, so a pause overlay can't
335
+ * resume a game a start screen — or the host — had already frozen. Audio stops
336
+ * with the engine.
337
+ */
338
+ pause(owner?: string): void;
339
+ resume(owner?: string): void;
140
340
  devMenu: RsdkDevMenuBridge;
341
+ game: RsdkGameBridge;
141
342
  /** True when the working dir is OPFS-backed (persistent) rather than in-memory. */
142
343
  persistent: boolean;
143
344
  /** Relative storage namespace used under /data (e.g. "sonic1", "sonic2"). */
@@ -159,39 +360,150 @@ export type Rsdkv4LoadConfig = EngineConfig & {
159
360
  * Examples: "sonic1", "sonic2", "my-pack/v1".
160
361
  */
161
362
  storageNamespace?: string;
363
+ /** Deprecated alias for `canvasEl`, kept for hosts written against 0.0.x. */
364
+ canvas?: HTMLCanvasElement;
162
365
  };
163
366
 
164
367
  /** Boot the RSDKv4 engine. */
165
368
  export async function load(config: Rsdkv4LoadConfig): Promise<Rsdkv4Instance> {
166
- const { canvas, assets, onEvent } = config;
369
+ const { assets, onEvent, attachTo } = config;
167
370
  const options = config.options as Rsdkv4Options | undefined;
168
- if (!canvas) throw new Error('rsdkv4: config.canvas is required');
371
+
372
+ const emit = (event: EngineEvent): void => {
373
+ try {
374
+ onEvent?.(event);
375
+ } catch {
376
+ // A host callback must not break the engine runtime.
377
+ }
378
+ };
379
+
380
+ // ----------------------------------------------------------------- render target
381
+
382
+ const providedCanvas = config.canvasEl ?? config.canvas ?? null;
383
+ if (!providedCanvas && !attachTo) {
384
+ throw new Error('rsdkv4: config.canvasEl or config.attachTo is required');
385
+ }
386
+
387
+ const ownsCanvas = providedCanvas === null;
388
+ const canvas = providedCanvas ?? document.createElement('canvas');
169
389
 
170
390
  // Emscripten's SDL2 port locates the canvas via document.querySelector('#canvas').
171
- if (canvas.id !== 'canvas') canvas.id = 'canvas';
391
+ if (canvas.id !== CANVAS_ID) {
392
+ if (canvas.id) {
393
+ console.warn(
394
+ `rsdkv4: renaming the render target's id from "${canvas.id}" to "${CANVAS_ID}" — SDL looks the canvas up by that id.`,
395
+ );
396
+ }
397
+ canvas.id = CANVAS_ID;
398
+ }
399
+
400
+ if (ownsCanvas) {
401
+ canvas.style.display = 'block';
402
+ canvas.style.maxWidth = '100%';
403
+ canvas.style.maxHeight = '100%';
404
+ canvas.style.height = 'auto';
405
+ canvas.classList.add('emscripten');
406
+
407
+ attachTo?.appendChild(canvas);
408
+ }
172
409
 
173
- const emit = (e: Parameters<NonNullable<EngineConfig['onEvent']>>[0]) => {
174
- try { onEvent?.(e); } catch { /* host handler must not break us */ }
410
+ // A canvas is not in the tab order and `focus()` on one is a silent no-op
411
+ // until it has a tabindex. -1 makes it focusable without adding a tab stop.
412
+ if (!canvas.hasAttribute('tabindex')) canvas.setAttribute('tabindex', '-1');
413
+ // …and the focus ring would draw a browser-blue box around the picture.
414
+ canvas.style.outline = 'none';
415
+
416
+ const swallowContextMenu = (event: Event): void => event.preventDefault();
417
+ canvas.addEventListener('contextmenu', swallowContextMenu);
418
+
419
+ const focusCanvas = (): void => {
420
+ if (!document.hasFocus()) window.focus();
421
+ canvas.focus({ preventScroll: true });
175
422
  };
176
423
 
424
+ const onPointerDown = (): void => focusCanvas();
425
+ canvas.addEventListener('pointerdown', onPointerDown);
426
+
427
+ // ----------------------------------------------------------------- sizing
428
+
429
+ /** The box the picture has to fit inside, in CSS pixels. */
430
+ const boxElement = ownsCanvas && attachTo ? attachTo : canvas;
431
+
432
+ const fitPicture = (): void => {
433
+ if (!ownsCanvas) return;
434
+ const box = boxElement.getBoundingClientRect();
435
+ if (box.width < 1 || box.height < 1) return;
436
+
437
+ const bw = manifest.video.baseWidth;
438
+ const bh = manifest.video.baseHeight;
439
+ const scale = Math.min(box.width / bw, box.height / bh);
440
+ canvas.style.width = `${Math.round(bw * scale)}px`;
441
+ canvas.style.height = `${Math.round(bh * scale)}px`;
442
+ };
443
+
444
+ fitPicture();
445
+
446
+ const onResize = (): void => fitPicture();
447
+ window.addEventListener('resize', onResize);
448
+ const boxObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize);
449
+ boxObserver?.observe(boxElement);
450
+
451
+ // Fullscreen transitions need a refit too.
452
+ const onFullscreenChange = (): void => {
453
+ fitPicture();
454
+ requestAnimationFrame(fitPicture);
455
+ };
456
+ document.addEventListener('fullscreenchange', onFullscreenChange);
457
+
458
+ // ----------------------------------------------------------------- emscripten
459
+
177
460
  const jsUrl = config.jsUrl ?? new URL('./rsdkv4.js', import.meta.url).href;
178
461
  const wasmUrl = config.wasmUrl ?? new URL('./rsdkv4.wasm', import.meta.url).href;
179
462
 
180
463
  // RSDKv4 keyboard bindings are the shared input script's default preset.
181
464
  if (typeof window !== 'undefined') (window as any).__gamepadKeyMap = manifest.input;
182
465
 
183
- const mod: any = await import(/* @vite-ignore */ jsUrl);
184
- const createRSDKv4 = mod.default;
466
+ const instantiate = async (bust?: string): Promise<any> => {
467
+ const withBust = (url: string): string => {
468
+ if (!bust) return url;
469
+ const parsed = new URL(url, location.href);
470
+ parsed.searchParams.set('_', bust);
471
+ return parsed.href;
472
+ };
473
+
474
+ const mod: any = await import(/* @vite-ignore */ withBust(jsUrl));
475
+ return mod.default({
476
+ canvas,
477
+ noInitialRun: true, // built with -sINVOKE_RUN=0; we mount data before main()
478
+ locateFile: (path: string) => (path.endsWith('.wasm') ? withBust(wasmUrl) : path),
479
+ print: (...a: unknown[]) => console.log('[rsdkv4]', ...a),
480
+ printErr: (...a: unknown[]) => console.error('[rsdkv4]', ...a),
481
+ onAbort: (reason: unknown) =>
482
+ emit({ type: 'error', error: new Error(`rsdkv4 aborted: ${reason}`) }),
483
+ });
484
+ };
185
485
 
186
- const Module: any = await createRSDKv4({
187
- canvas,
188
- noInitialRun: true, // built with -sINVOKE_RUN=0; we mount data before main()
189
- locateFile: (path: string) => (path.endsWith('.wasm') ? wasmUrl : path),
190
- print: (...a: unknown[]) => console.log('[rsdkv4]', ...a),
191
- printErr: (...a: unknown[]) => console.error('[rsdkv4]', ...a),
192
- onAbort: (reason: unknown) =>
193
- emit({ type: 'error', error: new Error(`rsdkv4 aborted: ${reason}`) }),
194
- });
486
+ let Module: any = await instantiate();
487
+
488
+ // Self-heal a stale engine.
489
+ //
490
+ // rsdkv4.js/.wasm are big and served by most static hosts with nothing but
491
+ // Last-Modified, so a browser will happily keep a build for hours. The
492
+ // symptom is nasty: fresh SDK code against an old engine, where the bridges
493
+ // this SDK calls simply don't exist and every getter quietly answers "empty" —
494
+ // no save slots, no characters, an empty pause menu. Rather than leave that to
495
+ // the reader's cache hygiene, notice it and fetch once past the cache.
496
+ if (!engineBridgeComplete(Module)) {
497
+ console.warn(
498
+ '[rsdkv4] the engine that loaded is missing bridges this SDK needs — refetching past the HTTP cache',
499
+ );
500
+ Module = await instantiate(String(Date.now()));
501
+ if (!engineBridgeComplete(Module)) {
502
+ console.error(
503
+ '[rsdkv4] still missing engine bridges after a cache-busting reload — rsdkv4.wasm is genuinely older than this SDK; rebuild it (`make build-wasm`)',
504
+ );
505
+ }
506
+ }
195
507
 
196
508
  // Mount the working dir first, so we can see what's already persisted (OPFS)
197
509
  // before deciding whether to pull assets.
@@ -234,10 +546,102 @@ export async function load(config: Rsdkv4LoadConfig): Promise<Rsdkv4Instance> {
234
546
  }
235
547
  // else: keep the persisted settings.ini
236
548
 
549
+ // Save data. `persist: null` means the host wants nothing kept, so honour it;
550
+ // otherwise pull any previous SData.bin in before the engine reads it.
551
+ const mirrorSaves = config.persist !== null;
552
+ if (mirrorSaves) await restoreSaveData(Module, storageNamespace, workDir);
553
+
237
554
  Module.FS.chdir(workDir);
238
555
 
239
- const setPaused = (paused: boolean) => {
556
+ /**
557
+ * Pause state, with an owner.
558
+ *
559
+ * `masterPaused` only freezes the logic loop — the audio callback keeps
560
+ * mixing, so a paused engine went on playing its music behind whatever the
561
+ * host put on top. Pausing therefore has to stop the sound too.
562
+ *
563
+ * The owner matters because pause/resume has several callers: a pause overlay,
564
+ * a start screen, the host itself. Closing an overlay must not resume a game
565
+ * something else had already frozen, so the *first* pause claims ownership and
566
+ * only that owner's `resume()` lifts it. Owner strings are the caller's to
567
+ * choose; the SDK's own default is 'host'.
568
+ */
569
+ type PauseOwner = string;
570
+ let pauseOwner: PauseOwner | null = null;
571
+
572
+ const applyPaused = (paused: boolean) => {
240
573
  if (typeof Module.web_devmenu_set_paused === 'function') Module.web_devmenu_set_paused(paused);
574
+ if (typeof Module.web_audio_set_paused === 'function') Module.web_audio_set_paused(paused);
575
+ };
576
+
577
+ const pauseWith = (owner: PauseOwner) => {
578
+ if (pauseOwner !== null) return; // already paused — whoever got there first keeps the claim
579
+ pauseOwner = owner;
580
+ applyPaused(true);
581
+ // A pause is a natural checkpoint for the save mirror.
582
+ if (mirrorSaves) void persistSaveData(Module, storageNamespace, workDir);
583
+ };
584
+
585
+ const resumeFrom = (owner: PauseOwner) => {
586
+ if (pauseOwner !== owner) return; // someone else's pause — leave it alone
587
+ pauseOwner = null;
588
+ applyPaused(false);
589
+ };
590
+
591
+ /** Legacy shape kept for `devMenu.setPaused` — always acts as the host. */
592
+ const setPaused = (paused: boolean) => {
593
+ if (paused) pauseWith('host');
594
+ else resumeFrom(pauseOwner ?? 'host');
595
+ };
596
+
597
+ const stopAudio = () => {
598
+ try {
599
+ Module.web_audio_stop?.();
600
+ } catch {
601
+ /* engine already down */
602
+ }
603
+ };
604
+
605
+ /**
606
+ * Call a `web_*` embind getter and parse its JSON.
607
+ *
608
+ * A missing function means the loaded rsdkv4.wasm predates that bridge — the
609
+ * usual cause is a browser serving a cached build. Say so out loud: silently
610
+ * returning an empty list makes a stale artifact look like an empty save file
611
+ * or a game with no playable characters.
612
+ */
613
+ const parseJson = <T>(fn: unknown, fallback: T, what: string): T => {
614
+ if (typeof fn !== 'function') {
615
+ console.error(
616
+ `[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.`,
617
+ );
618
+ return fallback;
619
+ }
620
+ try {
621
+ return JSON.parse((fn as () => string)());
622
+ } catch (e) {
623
+ console.error(`[rsdkv4] ${what} failed`, e);
624
+ return fallback;
625
+ }
626
+ };
627
+
628
+ const game: RsdkGameBridge = {
629
+ type: () => (typeof Module.web_game_type === 'function' ? Module.web_game_type() : 0),
630
+ players: () => parseJson(Module.web_get_players, [] as string[], 'web_get_players'),
631
+ saveSlots: () => parseJson(Module.web_get_save_slots, [] as RsdkSaveSlot[], 'web_get_save_slots'),
632
+ options: () => parseJson(Module.web_get_game_options, [] as RsdkGameOption[], 'web_get_game_options'),
633
+ setOption(key, value) {
634
+ Module.web_set_game_option?.(String(key), value | 0);
635
+ },
636
+ deleteSave(slot) {
637
+ Module.web_delete_save?.(slot | 0);
638
+ },
639
+ start(slot, player = 0) {
640
+ Module.web_start_game?.(slot == null ? -1 : slot | 0, player | 0);
641
+ // The overlay that drove this is done with the engine — hand it back.
642
+ pauseOwner = null;
643
+ applyPaused(false);
644
+ },
241
645
  };
242
646
 
243
647
  const devMenu: RsdkDevMenuBridge = {
@@ -261,25 +665,55 @@ export async function load(config: Rsdkv4LoadConfig): Promise<Rsdkv4Instance> {
261
665
  // Run main() from the OPFS/WASMFS working dir. simulate_infinite_loop schedules
262
666
  // the rAF loop and returns via a benign unwind Emscripten swallows.
263
667
  Module.callMain(['UsingCWD']);
668
+
669
+ // callMain only *schedules* the loop: Engine::Init runs on the first frame, and
670
+ // until it has, there is no game config, stage list or saveRAM to read. Waiting
671
+ // here means a host can call devMenu/game getters the moment load() resolves.
672
+ await waitForEngine(Module);
673
+
674
+ focusCanvas();
675
+
676
+ // The engine writes SData.bin at its own pace (new game, checkpoint, options
677
+ // change), so copy it out on a slow timer as well as on the way down.
678
+ const saveTimer = mirrorSaves
679
+ ? setInterval(() => void persistSaveData(Module, storageNamespace, workDir), 10_000)
680
+ : null;
681
+
264
682
  emit({ type: 'ready' });
265
683
 
266
684
  return {
267
685
  start() {},
268
- pause() { setPaused(true); },
269
- resume() { setPaused(false); },
686
+ /** `owner` lets a pause overlay avoid resuming a game something else paused. */
687
+ pause(owner: string = 'host') { pauseWith(owner); },
688
+ resume(owner: string = 'host') { resumeFrom(owner); },
270
689
  reset() {
271
690
  throw new Error('rsdkv4: reset() is not supported — destroy() and load() again');
272
691
  },
273
- setInput(preset) {
692
+ setInput(preset: InputPreset | KeyMap) {
274
693
  if (typeof window !== 'undefined') {
275
694
  (window as any).__gamepadKeyMap = preset ?? manifest.input;
276
695
  }
277
696
  },
278
697
  destroy() {
698
+ if (saveTimer !== null) clearInterval(saveTimer);
699
+ // Last chance to keep whatever the player earned this session.
700
+ if (mirrorSaves) void persistSaveData(Module, storageNamespace, workDir);
701
+
279
702
  try { Module.pauseMainLoop?.(); } catch { /* noop */ }
280
703
  try { setPaused(true); } catch { /* noop */ }
704
+ // Freezing the loop does not silence the audio callback — without this the
705
+ // music kept playing after the host went back to its launcher.
706
+ stopAudio();
707
+
708
+ canvas.removeEventListener('contextmenu', swallowContextMenu);
709
+ canvas.removeEventListener('pointerdown', onPointerDown);
710
+ document.removeEventListener('fullscreenchange', onFullscreenChange);
711
+ window.removeEventListener('resize', onResize);
712
+ boxObserver?.disconnect();
713
+ if (ownsCanvas) canvas.remove();
281
714
  },
282
715
  devMenu,
716
+ game,
283
717
  persistent,
284
718
  storageNamespace,
285
719
  purgeStorage() {