@wasm-gaming/rsdkv4-wasm 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +159 -213
  2. package/dist/rsdkv4/app/app.css +160 -0
  3. package/dist/rsdkv4/app/app.js +521 -0
  4. package/dist/rsdkv4/app/assets/player-knuckles.svg +757 -0
  5. package/dist/rsdkv4/app/assets/player-sonic.svg +720 -0
  6. package/dist/rsdkv4/app/assets/player-sonictails.svg +1414 -0
  7. package/dist/rsdkv4/app/assets/player-tails.svg +979 -0
  8. package/dist/rsdkv4/app/assets/stages-sonic1/special.png +0 -0
  9. package/dist/rsdkv4/app/assets/stages-sonic1/zone-1.png +0 -0
  10. package/dist/rsdkv4/app/assets/stages-sonic1/zone-2.png +0 -0
  11. package/dist/rsdkv4/app/assets/stages-sonic1/zone-3.png +0 -0
  12. package/dist/rsdkv4/app/assets/stages-sonic1/zone-4.png +0 -0
  13. package/dist/rsdkv4/app/assets/stages-sonic1/zone-5.png +0 -0
  14. package/dist/rsdkv4/app/assets/stages-sonic1/zone-6.png +0 -0
  15. package/dist/rsdkv4/app/assets/stages-sonic1/zone-7.png +0 -0
  16. package/dist/rsdkv4/app/assets/stages-sonic2/special.png +0 -0
  17. package/dist/rsdkv4/app/assets/stages-sonic2/zone-1.png +0 -0
  18. package/dist/rsdkv4/app/assets/stages-sonic2/zone-10.png +0 -0
  19. package/dist/rsdkv4/app/assets/stages-sonic2/zone-2.png +0 -0
  20. package/dist/rsdkv4/app/assets/stages-sonic2/zone-3.png +0 -0
  21. package/dist/rsdkv4/app/assets/stages-sonic2/zone-4.png +0 -0
  22. package/dist/rsdkv4/app/assets/stages-sonic2/zone-5.png +0 -0
  23. package/dist/rsdkv4/app/assets/stages-sonic2/zone-6.png +0 -0
  24. package/dist/rsdkv4/app/assets/stages-sonic2/zone-7.png +0 -0
  25. package/dist/rsdkv4/app/assets/stages-sonic2/zone-8.png +0 -0
  26. package/dist/rsdkv4/app/assets/stages-sonic2/zone-9.png +0 -0
  27. package/dist/rsdkv4/engine-specs.js +382 -0
  28. package/dist/rsdkv4/rsdkv4.options.d.ts +13 -5
  29. package/dist/rsdkv4/rsdkv4.options.d.ts.map +1 -1
  30. package/dist/rsdkv4/rsdkv4.options.js +6 -1
  31. package/dist/rsdkv4/rsdkv4.options.js.map +1 -1
  32. package/dist/rsdkv4/rsdkv4.sdk.d.ts +82 -2
  33. package/dist/rsdkv4/rsdkv4.sdk.d.ts.map +1 -1
  34. package/dist/rsdkv4/rsdkv4.sdk.js +217 -26
  35. package/dist/rsdkv4/rsdkv4.sdk.js.map +1 -1
  36. package/package.json +7 -4
  37. package/src/app/app.css +160 -0
  38. package/src/app/app.js +521 -0
  39. package/src/rsdkv4.options.ts +14 -6
  40. package/src/rsdkv4.sdk.ts +283 -26
@@ -185,6 +185,11 @@ export interface Rsdkv4InputDefaults {
185
185
  /**
186
186
  * The engine's own default bindings. See {@link Rsdkv4InputDefaults}.
187
187
  *
188
+ * Directions are the arrow keys. This build's `ProcessInput()` also reads `KeyW`,
189
+ * `KeyA`, `KeyS` and `KeyD` as a second set of them, and takes those three scancodes
190
+ * off `x`, `y` and `z` to do it — so the table below is what a *claiming* host should
191
+ * bind, and the engine under SDL polling answers to both.
192
+ *
188
193
  * This replaces the old `RSDKV4_KEYMAP`, which nothing read and which was wrong in
189
194
  * three ways for want of ever being checked against the engine: it listed 9 of the
190
195
  * 14 buttons, and bound `select` to `ShiftLeft` where the engine's default is `Tab`.
@@ -344,12 +349,15 @@ export interface Rsdkv4Assets {
344
349
  export interface Rsdkv4Storage {
345
350
  /**
346
351
  * Per-game folder for the pack, the settings and the save file, so two games do not
347
- * overwrite each other. Examples: `"sonic1"`, `"rsdkv4/Sonic2"`.
352
+ * overwrite each other. Examples: `"sonic-1"`, `"sonic-2/profile-a"`.
348
353
  *
349
- * It is a path under the engine's working root *and* the OPFS directory the SDK
350
- * mirrors save data into, which is what lets a host read `<namespace>/SData.bin`
351
- * itself. Anything that is not a usable relative path normalises to `"default"` —
352
- * including the object you get from interpolating a game record by mistake.
354
+ * Relative to the engine's own folder: the SDK always prefixes `rsdkv4/`, so the
355
+ * files land in `rsdkv4/<namespace>/` — in WASMFS (`/data/rsdkv4/<namespace>`) and in
356
+ * OPFS alike — and no namespace can reach another engine's files. A host reads the
357
+ * resolved path from `play.storagePath`. Segments are cleaned (`.`/`..` dropped,
358
+ * odd characters → `_`); anything unusable normalises to `"default"`, including the
359
+ * object you get from interpolating a game record by mistake. A leading `rsdkv4/`
360
+ * (the old convention) is stripped with a warning.
353
361
  *
354
362
  * @defaultValue `"default"`
355
363
  */
@@ -491,7 +499,7 @@ export const RSDKV4_SPEC: EngineSpec<Rsdkv4Payloads> = {
491
499
  namespace: {
492
500
  default: 'default',
493
501
  live: false,
494
- description: "Folder for this game's pack, settings and save file.",
502
+ description: "Folder for this game's pack, settings and save file, inside the engine's own rsdkv4/ folder (OPFS rsdkv4/<namespace>/).",
495
503
  },
496
504
  persist: {
497
505
  enum: ['opfs', null],
package/src/rsdkv4.sdk.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * const play = await new Rsdkv4SDK()
8
8
  * .mount(document.querySelector('#stage'))
9
9
  * .assets({ data: () => library.readBytes('Sonic2') })
10
- * .storage({ namespace: 'rsdkv4/Sonic2' })
10
+ * .storage({ namespace: 'sonic-2' }) // → OPFS rsdkv4/sonic-2/
11
11
  * .config({ startMenu: 'host' })
12
12
  * .on('saves', ({ detail }) => paintSlots(detail))
13
13
  * .start({ slot: 0 });
@@ -85,6 +85,16 @@ export type Rsdkv4Events_ = EventsOf<Rsdkv4Payloads>;
85
85
  const WORK_ROOT = '/data';
86
86
  const DEFAULT_STORAGE_NAMESPACE = 'default';
87
87
 
88
+ /**
89
+ * This engine's folder in WASMFS / OPFS. Every rsdkv4 file lives under it, whatever
90
+ * namespace a host picks, so two engines can never share a working dir — the host
91
+ * chooses the sub-folder, never the root. Same value as {@link Rsdkv4SDK.id}.
92
+ */
93
+ const ENGINE_ID = 'rsdkv4';
94
+
95
+ /** The files this engine keeps in its namespace — and the only ones it ever migrates. */
96
+ const ENGINE_FILES = ['Data.rsdk', 'settings.ini', 'SData.bin'] as const;
97
+
88
98
  /** RSDKv4's save file, written next to the game data in the working dir. */
89
99
  const SAVE_FILE = 'SData.bin';
90
100
 
@@ -116,6 +126,7 @@ const ENGINE_BRIDGES = [
116
126
  'web_start_game',
117
127
  'web_audio_stop',
118
128
  'web_devmenu_get_stage_list',
129
+ 'web_devmenu_open',
119
130
  'web_input_set_source',
120
131
  ] as const;
121
132
 
@@ -129,6 +140,34 @@ const INPUT_PULL_HOOK = '__rsdkv4_input_pull';
129
140
  /** How often a pull that keeps throwing is allowed to say so. See {@link Rsdkv4Play}. */
130
141
  const INPUT_ERROR_INTERVAL_MS = 1000;
131
142
 
143
+ /**
144
+ * Keys whose browser default action is swallowed while the canvas has focus — the
145
+ * arrows and Space scroll the page under the game, Tab takes the focus off the canvas
146
+ * and with it every key the engine was reading.
147
+ *
148
+ * Both `KeyboardEvent.code` and `.key` spellings are in here because the set is
149
+ * matched against each: `code` is the physical key (layout-independent, which is what
150
+ * the engine binds), `key` is what it produced, and a browser or a remapping tool that
151
+ * reports only one of the two still lands.
152
+ *
153
+ * WASD is included because this build's `ProcessInput()` reads those scancodes as a
154
+ * second set of directions. They have no default action to prevent today, so this
155
+ * costs nothing and stops mattering the day the page grows a shortcut on one of them.
156
+ */
157
+ const ENGINE_KEYS = new Set([
158
+ 'ArrowUp',
159
+ 'ArrowDown',
160
+ 'ArrowLeft',
161
+ 'ArrowRight',
162
+ 'KeyW',
163
+ 'KeyA',
164
+ 'KeyS',
165
+ 'KeyD',
166
+ 'Space',
167
+ ' ',
168
+ 'Tab',
169
+ ]);
170
+
132
171
  /** The Emscripten module. Untyped on purpose: it is generated glue, not our surface. */
133
172
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
134
173
  type EmscriptenModule = any;
@@ -150,21 +189,37 @@ async function resolveAsset(asset: Rsdkv4Asset | undefined): Promise<Uint8Array
150
189
  return toUint8(typeof asset === 'function' ? await asset() : asset);
151
190
  }
152
191
 
153
- /** Normalize a host-provided storage namespace into a safe relative path. */
154
- function normalizeStorageNamespace(namespace: unknown): string {
155
- if (typeof namespace !== 'string' || !namespace.trim()) return DEFAULT_STORAGE_NAMESPACE;
156
-
157
- const cleaned = namespace
158
- .split('/')
159
- .map((segment) => segment.trim())
160
- .filter(Boolean)
161
- .map((segment) => segment.replace(/[^A-Za-z0-9._-]/g, '_'))
162
- .filter(Boolean)
163
- .join('/');
164
-
165
- return cleaned || DEFAULT_STORAGE_NAMESPACE;
192
+ /**
193
+ * Where an engine keeps one namespace: `<engineId>/<namespace>`, relative to the OPFS
194
+ * root (which is WASMFS `/data`). Written with nothing rsdkv4-specific in it, so it can
195
+ * move verbatim into engine-specs.
196
+ *
197
+ * The host's namespace is cleaned segment by segment: blanks dropped, `.` and `..`
198
+ * dropped (they used to survive, and `'../x'` walked out of the work root), anything
199
+ * outside `[A-Za-z0-9._-]` turned into `_`. Nothing usable left means `'default'`.
200
+ * A namespace that already starts with the engine id has it stripped once, so a host
201
+ * written against the old convention (`'rsdkv4/Sonic1'`) lands where it always did.
202
+ */
203
+ export function storagePath(
204
+ engineId: string,
205
+ namespace: unknown,
206
+ ): { namespace: string; path: string; prefixed: boolean } {
207
+ const segments =
208
+ typeof namespace === 'string'
209
+ ? namespace
210
+ .split('/')
211
+ .map((segment) => segment.trim())
212
+ .filter((segment) => segment && segment !== '.' && segment !== '..')
213
+ .map((segment) => segment.replace(/[^A-Za-z0-9._-]/g, '_'))
214
+ : [];
215
+ const prefixed = segments.length > 1 && segments[0] === engineId;
216
+ if (prefixed) segments.shift();
217
+ const cleaned = segments.join('/') || DEFAULT_STORAGE_NAMESPACE;
218
+ return { namespace: cleaned, path: `${engineId}/${cleaned}`, prefixed };
166
219
  }
167
220
 
221
+ let warnedPrefixed = false;
222
+
168
223
  /** The plain values behind a descriptor table — what the engine actually needs. */
169
224
  function valuesOf<T>(described: PropertiesDefinition<T>): T {
170
225
  const out: Record<string, unknown> = {};
@@ -289,18 +344,18 @@ function mountWorkingDir(
289
344
  }
290
345
 
291
346
  /**
292
- * OPFS handle for `<namespace>/`, reached with the plain (async) OPFS API from JS —
347
+ * OPFS handle for `<engineId>/<namespace>/` (a {@link storagePath}), reached with the plain (async) OPFS API from JS —
293
348
  * which, unlike the WASM side's sync access handles, works on the main thread of any
294
349
  * page. Returns null when OPFS isn't available at all.
295
350
  */
296
351
  async function opfsNamespaceDir(
297
- namespace: string,
352
+ path: string,
298
353
  create: boolean,
299
354
  ): Promise<FileSystemDirectoryHandle | null> {
300
355
  if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return null;
301
356
  try {
302
357
  let dir = await navigator.storage.getDirectory();
303
- for (const segment of namespace.split('/').filter(Boolean)) {
358
+ for (const segment of path.split('/').filter(Boolean)) {
304
359
  dir = await dir.getDirectoryHandle(segment, { create });
305
360
  }
306
361
  return dir;
@@ -309,6 +364,42 @@ async function opfsNamespaceDir(
309
364
  }
310
365
  }
311
366
 
367
+ /**
368
+ * Before the namespace lived under `rsdkv4/`, the default one was a top-level
369
+ * `default/` — a folder snes9x, mGBA and geolith also fall back to. Copy rsdkv4's own
370
+ * files out of it into `rsdkv4/default/` when the new folder lacks them. Copy, never
371
+ * move or delete: whatever else is in `default/` is not ours to touch.
372
+ */
373
+ async function migrateLegacyDefault(): Promise<void> {
374
+ const legacy = await opfsNamespaceDir(DEFAULT_STORAGE_NAMESPACE, false);
375
+ if (!legacy) return;
376
+ let target: FileSystemDirectoryHandle | null = null;
377
+ for (const name of ENGINE_FILES) {
378
+ let source: File;
379
+ try {
380
+ source = await (await legacy.getFileHandle(name)).getFile();
381
+ } catch {
382
+ continue;
383
+ }
384
+ target ??= await opfsNamespaceDir(`${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`, true);
385
+ if (!target) return;
386
+ try {
387
+ await target.getFileHandle(name);
388
+ continue; // already there: the new layout wins
389
+ } catch {
390
+ /* missing — copy it */
391
+ }
392
+ try {
393
+ const writable = await (await target.getFileHandle(name, { create: true })).createWritable();
394
+ await writable.write(await source.arrayBuffer());
395
+ await writable.close();
396
+ console.info(`[rsdkv4] copied ${DEFAULT_STORAGE_NAMESPACE}/${name} → ${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}/${name}`);
397
+ } catch (e) {
398
+ console.warn(`[rsdkv4] could not migrate ${DEFAULT_STORAGE_NAMESPACE}/${name}`, e);
399
+ }
400
+ }
401
+ }
402
+
312
403
  /**
313
404
  * Wait for `Engine::Init`, which runs on the engine's first frame (main.cpp's
314
405
  * main_loop), not inside `callMain`. Gives up after ~4s rather than hanging the host:
@@ -331,11 +422,49 @@ function engineBridgeComplete(Module: EmscriptenModule): boolean {
331
422
 
332
423
  // ---------------------------------------------------------------- this engine's own surface
333
424
 
425
+ /**
426
+ * A screen of RSDKv4's own in-canvas Dev Menu (Debug.cpp), as {@link
427
+ * RsdkDevMenuBridge.open} names them.
428
+ *
429
+ * `'categories'` is the engine's `SELECT A STAGE LIST` — PRESENTATION / REGULAR /
430
+ * SPECIAL / BONUS — and `'stages'` the scene list under one of them. Those two are the
431
+ * only native way to reach the BONUS list: the packs' own menu scenes (Sonic 1 and 2's
432
+ * `LEVEL SELECT` and `STAGE MENU`) can load a regular stage and nothing else.
433
+ */
434
+ export type RsdkDevMenuScreen = 'main' | 'player' | 'categories' | 'stages';
435
+
436
+ /**
437
+ * The order `web_devmenu_open`'s first argument counts in — the index into this array
438
+ * is what crosses to C++, so it has to stay in step with WebDevMenu.cpp (written by
439
+ * scripts/build.sh). Names on this side, a number on the wire.
440
+ */
441
+ const DEV_MENU_SCREENS: readonly RsdkDevMenuScreen[] = ['main', 'player', 'categories', 'stages'];
442
+
334
443
  /** RSDKv4-specific bridge for a host's debug/stage-select UI. */
335
444
  export interface RsdkDevMenuBridge {
336
445
  getStageList(): Array<{ name: string; stages: Array<{ name: string }> }>;
337
446
  loadStage(categoryIdx: number, stageIdx: number): void;
338
447
  setPaused(paused: boolean): void;
448
+ /**
449
+ * Whether the loaded wasm carries {@link open}. False means an engine built before
450
+ * the bridge existed — a host should hide the button rather than offer a dead one.
451
+ */
452
+ readonly canOpen: boolean;
453
+ /**
454
+ * Open the engine's own Dev Menu at `screen`, with `category` selecting the stage
455
+ * list for `'categories'` (preselected) and `'stages'` (listed). Categories are the
456
+ * engine's own indices, the ones {@link getStageList} is ordered by — 2 is BONUS.
457
+ *
458
+ * This is what Escape does natively: the request is handed to the engine thread
459
+ * (`ENGINE_INITDEVMENU`) and the menu comes up on its next frame, so **the running
460
+ * stage is torn down** — `initDevMenu()` clears graphics and animation data. Save
461
+ * files are untouched; the current run is not.
462
+ *
463
+ * The menu is driven by the engine's own input and does not run while the SDK holds
464
+ * a pause, so resume before calling. Returns false when the engine cannot be reached
465
+ * or the wasm predates the bridge ({@link canOpen}).
466
+ */
467
+ open(screen?: RsdkDevMenuScreen, category?: number): boolean;
339
468
  }
340
469
 
341
470
  /**
@@ -403,6 +532,34 @@ export interface RsdkInputBridge {
403
532
  release(): PromisePlay<Rsdkv4Payloads>;
404
533
  /** Whether a claim is held. The `options()` descriptor deliberately will not say. */
405
534
  readonly claimed: boolean;
535
+ /**
536
+ * What the engine has held **right now**, as the bitmask a claim writes: bit 0 is
537
+ * `up`, bit 13 is `select`, in {@link RSDKV4_BUTTONS} order.
538
+ *
539
+ * This is the read-back to the claim's write, and it answers the one question no
540
+ * amount of listening on the page can: *did the button reach the engine?* A key
541
+ * that fires `keydown` in the browser and never lights a bit here died between SDL
542
+ * and `ProcessInput()`; one that lights the bit and changes nothing on screen was
543
+ * received and the game chose not to act on it. Those two are otherwise the same
544
+ * silence, and telling them apart is the difference between a real input bug and
545
+ * Sonic simply refusing to roll while a direction is held.
546
+ *
547
+ * Works whether or not a claim is held — under SDL polling it is the keyboard and
548
+ * the pad as the engine read them. `0` when the engine is not up.
549
+ *
550
+ * Two things it is not. It is `hold`, not `press`: a button reads the same on the
551
+ * frame it goes down and on the hundredth frame it stays down. And it is the
552
+ * engine's view *after* the B→A fold, so in-stage the jump key `x` lights `a` and
553
+ * `b` never lights at all — which is the fold working, not a lost button.
554
+ */
555
+ readonly mask: number;
556
+ /**
557
+ * {@link mask}, spelled as the fourteen named booleans — the same shape a claim
558
+ * hands over, so a host can diff what it sent against what arrived.
559
+ *
560
+ * A fresh object on every read; it is a snapshot, not a live view.
561
+ */
562
+ readonly held: Rsdkv4Buttons;
406
563
  }
407
564
 
408
565
  /**
@@ -446,7 +603,8 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
446
603
 
447
604
  #persistent = false;
448
605
  #namespace = DEFAULT_STORAGE_NAMESPACE;
449
- #workDir = `${WORK_ROOT}/${DEFAULT_STORAGE_NAMESPACE}`;
606
+ #storagePath = `${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`;
607
+ #workDir = `${WORK_ROOT}/${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`;
450
608
  #mirrorSaves = true;
451
609
  #saveTimer: ReturnType<typeof setInterval> | null = null;
452
610
  /** Last bytes mirrored out, so the `saves` event means "changed" and not "checked". */
@@ -602,6 +760,7 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
602
760
 
603
761
  canvas.addEventListener('contextmenu', this.#swallowContextMenu);
604
762
  canvas.addEventListener('pointerdown', this.#onPointerDown);
763
+ canvas.addEventListener('keydown', this.#onKeyDown);
605
764
  window.addEventListener('resize', this.#onResize);
606
765
  // Fullscreen transitions need a refit too.
607
766
  document.addEventListener('fullscreenchange', this.#onFullscreenChange);
@@ -754,6 +913,15 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
754
913
  return this.#namespace;
755
914
  }
756
915
 
916
+ /**
917
+ * Where this session's files live, relative to the OPFS root: `rsdkv4/<namespace>`.
918
+ * The path a host walks with `navigator.storage.getDirectory()` to stage a
919
+ * `Data.rsdk` before booting, or to read `SData.bin` after.
920
+ */
921
+ get storagePath(): string {
922
+ return this.#storagePath;
923
+ }
924
+
757
925
  /**
758
926
  * Input ownership, named. `play.input.claim(buttons)` and `play.input.release()`
759
927
  * are exactly `options({ buttons })` and `options({ buttons: null })` — same state,
@@ -769,9 +937,34 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
769
937
  get claimed(): boolean {
770
938
  return play.#buttons !== null;
771
939
  },
940
+ get mask(): number {
941
+ return play.#engineMask();
942
+ },
943
+ get held(): Rsdkv4Buttons {
944
+ const mask = play.#engineMask();
945
+ const held = {} as Rsdkv4Buttons;
946
+ for (let bit = 0; bit < RSDKV4_BUTTONS.length; bit++) {
947
+ held[RSDKV4_BUTTONS[bit]] = (mask & (1 << bit)) !== 0;
948
+ }
949
+ return held;
950
+ },
772
951
  };
773
952
  }
774
953
 
954
+ /**
955
+ * The engine's own `web_input_get_mask()`, or 0 when there is no engine to ask.
956
+ *
957
+ * Deliberately quiet about a module that does not have the bridge: unlike a claim,
958
+ * which silently does nothing and deserves the console error `#applyButtons` gives
959
+ * it, this is a read — a host polling it once a frame for an overlay would turn one
960
+ * old artifact into sixty messages a second.
961
+ */
962
+ #engineMask(): number {
963
+ const Module = this.#module;
964
+ if (!Module || typeof Module.web_input_get_mask !== 'function') return 0;
965
+ return Module.web_input_get_mask() | 0;
966
+ }
967
+
775
968
  // ---- Host-driven input ----
776
969
 
777
970
  /**
@@ -928,6 +1121,24 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
928
1121
  if (paused) this.pause('host');
929
1122
  else this.resume(this.#pauseOwner ?? 'host');
930
1123
  },
1124
+ canOpen: typeof Module.web_devmenu_open === 'function',
1125
+ open: (screen = 'main', category = 0) => {
1126
+ if (typeof Module.web_devmenu_open !== 'function') {
1127
+ console.warn(
1128
+ '[rsdkv4] this rsdkv4.wasm has no web_devmenu_open — rebuild it (`make build-wasm`), or read devMenu.canOpen before offering the button',
1129
+ );
1130
+ return false;
1131
+ }
1132
+ const index = DEV_MENU_SCREENS.indexOf(screen);
1133
+ if (index < 0) {
1134
+ console.warn(
1135
+ `[rsdkv4] "${screen}" is not a dev menu screen — one of ${DEV_MENU_SCREENS.join(', ')}`,
1136
+ );
1137
+ return false;
1138
+ }
1139
+ Module.web_devmenu_open(index, category | 0);
1140
+ return true;
1141
+ },
931
1142
  };
932
1143
  }
933
1144
 
@@ -973,8 +1184,17 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
973
1184
  // Mount the working dir first, so we can see what is already persisted (OPFS)
974
1185
  // before deciding whether to pull assets.
975
1186
  this.#persistent = mountWorkingDir(Module, storage.persist).persistent;
976
- this.#namespace = normalizeStorageNamespace(storage.namespace);
977
- this.#workDir = `${WORK_ROOT}/${this.#namespace}`;
1187
+ const resolved = storagePath(ENGINE_ID, storage.namespace);
1188
+ if (resolved.prefixed && !warnedPrefixed) {
1189
+ warnedPrefixed = true;
1190
+ console.warn(
1191
+ `[rsdkv4] storage.namespace no longer needs the "${ENGINE_ID}/" prefix — the engine adds it. Pass "${resolved.namespace}".`,
1192
+ );
1193
+ }
1194
+ this.#namespace = resolved.namespace;
1195
+ this.#storagePath = resolved.path;
1196
+ this.#workDir = `${WORK_ROOT}/${resolved.path}`;
1197
+ if (resolved.namespace === DEFAULT_STORAGE_NAMESPACE) await migrateLegacyDefault();
978
1198
  this.#mirrorSaves = storage.persist !== null;
979
1199
  ensureDir(Module, this.#workDir);
980
1200
 
@@ -999,8 +1219,24 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
999
1219
 
1000
1220
  // The engine writes SData.bin at its own pace (new game, checkpoint, options
1001
1221
  // change), so copy it out on a slow timer as well as on the way down.
1222
+ //
1223
+ // The timer alone loses saves, and by a wide margin: `destroy()` is the only
1224
+ // other flush, and a browser tab that is closed or reloaded never calls it. A
1225
+ // player who finished an act and hit reload inside the next ten seconds had the
1226
+ // save written by the engine, held in the wasm heap, and dropped on the floor —
1227
+ // which reads as "my progress did not stick" and looks exactly like a save
1228
+ // system that does not work.
1229
+ //
1230
+ // `visibilitychange` → hidden is the flush that actually fires: it covers the
1231
+ // tab being switched away from, the window being minimised, the phone going to
1232
+ // the home screen, and — unlike `beforeunload`, which browsers increasingly
1233
+ // ignore — navigating away and closing. `pagehide` backs it up for the bfcache
1234
+ // path. Cheap to over-fire: the mirror compares bytes and returns without
1235
+ // touching OPFS when nothing moved.
1002
1236
  if (this.#mirrorSaves) {
1003
1237
  this.#saveTimer = setInterval(() => void this.#mirrorSaveData(), 10_000);
1238
+ document.addEventListener('visibilitychange', this.#onVisibilityChange);
1239
+ window.addEventListener('pagehide', this.#onPageHide);
1004
1240
  }
1005
1241
 
1006
1242
  return Module;
@@ -1071,7 +1307,7 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
1071
1307
  if (explicit) Module.FS.writeFile(dataPath, explicit);
1072
1308
  else {
1073
1309
  console.log(
1074
- `[rsdkv4] Data.rsdk already in persistent storage (${this.#namespace}) — skipping fetch`,
1310
+ `[rsdkv4] Data.rsdk already in persistent storage (${this.#storagePath}) — skipping fetch`,
1075
1311
  );
1076
1312
  }
1077
1313
  } else {
@@ -1103,7 +1339,7 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
1103
1339
  * it changes.
1104
1340
  */
1105
1341
  async #restoreSaveData(Module: EmscriptenModule): Promise<void> {
1106
- const dir = await opfsNamespaceDir(this.#namespace, false);
1342
+ const dir = await opfsNamespaceDir(this.#storagePath, false);
1107
1343
  if (!dir) return;
1108
1344
  try {
1109
1345
  const file = await (await dir.getFileHandle(SAVE_FILE)).getFile();
@@ -1118,7 +1354,7 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
1118
1354
  }
1119
1355
 
1120
1356
  /**
1121
- * Copy SData.bin out of the wasm into `<namespace>/`, and announce it if it moved.
1357
+ * Copy SData.bin out of the wasm into `rsdkv4/<namespace>/`, and announce it if it moved.
1122
1358
  *
1123
1359
  * The division of labour the session settled on: the SDK owns getting the bytes out
1124
1360
  * of the wasm and into the namespace both sides already agree on; a host owns
@@ -1140,7 +1376,7 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
1140
1376
  }
1141
1377
  if (sameBytes(bytes, this.#lastSave)) return;
1142
1378
 
1143
- const dir = await opfsNamespaceDir(this.#namespace, true);
1379
+ const dir = await opfsNamespaceDir(this.#storagePath, true);
1144
1380
  if (dir) {
1145
1381
  try {
1146
1382
  const handle = await dir.getFileHandle(SAVE_FILE, { create: true });
@@ -1159,8 +1395,26 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
1159
1395
 
1160
1396
  // ---- The render target ----
1161
1397
 
1398
+ /**
1399
+ * Flush the save when the page is being put away. See where these are registered
1400
+ * for why the ten-second timer is not enough on its own.
1401
+ */
1402
+ #onVisibilityChange = (): void => {
1403
+ if (document.visibilityState === 'hidden' && this.#mirrorSaves) void this.#mirrorSaveData();
1404
+ };
1405
+ #onPageHide = (): void => {
1406
+ if (this.#mirrorSaves) void this.#mirrorSaveData();
1407
+ };
1408
+
1162
1409
  #swallowContextMenu = (event: Event): void => event.preventDefault();
1163
1410
  #onPointerDown = (): void => this.#focusCanvas();
1411
+ #onKeyDown = (event: KeyboardEvent): void => {
1412
+ // Only the browser's own default action is suppressed — the arrows and Space
1413
+ // scroll the page, Tab walks the focus out of the canvas. The event is left to
1414
+ // propagate: SDL's own handler sits on `window` in the bubble phase, so calling
1415
+ // stopPropagation() here would be the engine's whole keyboard.
1416
+ if (ENGINE_KEYS.has(event.code) || ENGINE_KEYS.has(event.key)) event.preventDefault();
1417
+ };
1164
1418
  #onResize = (): void => this.#fitPicture();
1165
1419
  #onFullscreenChange = (): void => {
1166
1420
  this.#fitPicture();
@@ -1214,9 +1468,12 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
1214
1468
  if (canvas) {
1215
1469
  canvas.removeEventListener('contextmenu', this.#swallowContextMenu);
1216
1470
  canvas.removeEventListener('pointerdown', this.#onPointerDown);
1471
+ canvas.removeEventListener('keydown', this.#onKeyDown);
1217
1472
  if (this.#ownsCanvas) canvas.remove();
1218
1473
  }
1219
1474
  document.removeEventListener('fullscreenchange', this.#onFullscreenChange);
1475
+ document.removeEventListener('visibilitychange', this.#onVisibilityChange);
1476
+ window.removeEventListener('pagehide', this.#onPageHide);
1220
1477
  window.removeEventListener('resize', this.#onResize);
1221
1478
  this.#boxObserver?.disconnect();
1222
1479
  this.#boxObserver = null;
@@ -1256,9 +1513,9 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
1256
1513
  * Everything on it is the contract's — see the module doc for the shape of a call.
1257
1514
  */
1258
1515
  export class Rsdkv4SDK extends EngineSDKBase<Rsdkv4Payloads> {
1259
- static readonly id = 'rsdkv4';
1516
+ static readonly id = ENGINE_ID;
1260
1517
  /** Kept in step with package.json by `node scripts/sync-version.mjs`. */
1261
- static readonly version = '0.2.0';
1518
+ static readonly version = '0.3.0';
1262
1519
  static readonly name = 'Retro Software Development Kit v4';
1263
1520
  static readonly description =
1264
1521
  '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.';