@wasm-gaming/rsdkv4-wasm 0.2.1 → 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 +8 -5
  29. package/dist/rsdkv4/rsdkv4.options.d.ts.map +1 -1
  30. package/dist/rsdkv4/rsdkv4.options.js +1 -1
  31. package/dist/rsdkv4/rsdkv4.options.js.map +1 -1
  32. package/dist/rsdkv4/rsdkv4.sdk.d.ts +24 -2
  33. package/dist/rsdkv4/rsdkv4.sdk.d.ts.map +1 -1
  34. package/dist/rsdkv4/rsdkv4.sdk.js +104 -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 +9 -6
  40. package/src/rsdkv4.sdk.ts +107 -26
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
 
@@ -179,21 +189,37 @@ async function resolveAsset(asset: Rsdkv4Asset | undefined): Promise<Uint8Array
179
189
  return toUint8(typeof asset === 'function' ? await asset() : asset);
180
190
  }
181
191
 
182
- /** Normalize a host-provided storage namespace into a safe relative path. */
183
- function normalizeStorageNamespace(namespace: unknown): string {
184
- if (typeof namespace !== 'string' || !namespace.trim()) return DEFAULT_STORAGE_NAMESPACE;
185
-
186
- const cleaned = namespace
187
- .split('/')
188
- .map((segment) => segment.trim())
189
- .filter(Boolean)
190
- .map((segment) => segment.replace(/[^A-Za-z0-9._-]/g, '_'))
191
- .filter(Boolean)
192
- .join('/');
193
-
194
- 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 };
195
219
  }
196
220
 
221
+ let warnedPrefixed = false;
222
+
197
223
  /** The plain values behind a descriptor table — what the engine actually needs. */
198
224
  function valuesOf<T>(described: PropertiesDefinition<T>): T {
199
225
  const out: Record<string, unknown> = {};
@@ -318,18 +344,18 @@ function mountWorkingDir(
318
344
  }
319
345
 
320
346
  /**
321
- * 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 —
322
348
  * which, unlike the WASM side's sync access handles, works on the main thread of any
323
349
  * page. Returns null when OPFS isn't available at all.
324
350
  */
325
351
  async function opfsNamespaceDir(
326
- namespace: string,
352
+ path: string,
327
353
  create: boolean,
328
354
  ): Promise<FileSystemDirectoryHandle | null> {
329
355
  if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return null;
330
356
  try {
331
357
  let dir = await navigator.storage.getDirectory();
332
- for (const segment of namespace.split('/').filter(Boolean)) {
358
+ for (const segment of path.split('/').filter(Boolean)) {
333
359
  dir = await dir.getDirectoryHandle(segment, { create });
334
360
  }
335
361
  return dir;
@@ -338,6 +364,42 @@ async function opfsNamespaceDir(
338
364
  }
339
365
  }
340
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
+
341
403
  /**
342
404
  * Wait for `Engine::Init`, which runs on the engine's first frame (main.cpp's
343
405
  * main_loop), not inside `callMain`. Gives up after ~4s rather than hanging the host:
@@ -541,7 +603,8 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
541
603
 
542
604
  #persistent = false;
543
605
  #namespace = DEFAULT_STORAGE_NAMESPACE;
544
- #workDir = `${WORK_ROOT}/${DEFAULT_STORAGE_NAMESPACE}`;
606
+ #storagePath = `${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`;
607
+ #workDir = `${WORK_ROOT}/${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`;
545
608
  #mirrorSaves = true;
546
609
  #saveTimer: ReturnType<typeof setInterval> | null = null;
547
610
  /** Last bytes mirrored out, so the `saves` event means "changed" and not "checked". */
@@ -850,6 +913,15 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
850
913
  return this.#namespace;
851
914
  }
852
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
+
853
925
  /**
854
926
  * Input ownership, named. `play.input.claim(buttons)` and `play.input.release()`
855
927
  * are exactly `options({ buttons })` and `options({ buttons: null })` — same state,
@@ -1112,8 +1184,17 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
1112
1184
  // Mount the working dir first, so we can see what is already persisted (OPFS)
1113
1185
  // before deciding whether to pull assets.
1114
1186
  this.#persistent = mountWorkingDir(Module, storage.persist).persistent;
1115
- this.#namespace = normalizeStorageNamespace(storage.namespace);
1116
- 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();
1117
1198
  this.#mirrorSaves = storage.persist !== null;
1118
1199
  ensureDir(Module, this.#workDir);
1119
1200
 
@@ -1226,7 +1307,7 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
1226
1307
  if (explicit) Module.FS.writeFile(dataPath, explicit);
1227
1308
  else {
1228
1309
  console.log(
1229
- `[rsdkv4] Data.rsdk already in persistent storage (${this.#namespace}) — skipping fetch`,
1310
+ `[rsdkv4] Data.rsdk already in persistent storage (${this.#storagePath}) — skipping fetch`,
1230
1311
  );
1231
1312
  }
1232
1313
  } else {
@@ -1258,7 +1339,7 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
1258
1339
  * it changes.
1259
1340
  */
1260
1341
  async #restoreSaveData(Module: EmscriptenModule): Promise<void> {
1261
- const dir = await opfsNamespaceDir(this.#namespace, false);
1342
+ const dir = await opfsNamespaceDir(this.#storagePath, false);
1262
1343
  if (!dir) return;
1263
1344
  try {
1264
1345
  const file = await (await dir.getFileHandle(SAVE_FILE)).getFile();
@@ -1273,7 +1354,7 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
1273
1354
  }
1274
1355
 
1275
1356
  /**
1276
- * 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.
1277
1358
  *
1278
1359
  * The division of labour the session settled on: the SDK owns getting the bytes out
1279
1360
  * of the wasm and into the namespace both sides already agree on; a host owns
@@ -1295,7 +1376,7 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
1295
1376
  }
1296
1377
  if (sameBytes(bytes, this.#lastSave)) return;
1297
1378
 
1298
- const dir = await opfsNamespaceDir(this.#namespace, true);
1379
+ const dir = await opfsNamespaceDir(this.#storagePath, true);
1299
1380
  if (dir) {
1300
1381
  try {
1301
1382
  const handle = await dir.getFileHandle(SAVE_FILE, { create: true });
@@ -1432,9 +1513,9 @@ export class Rsdkv4Play extends EnginePlayBase<Rsdkv4Payloads> {
1432
1513
  * Everything on it is the contract's — see the module doc for the shape of a call.
1433
1514
  */
1434
1515
  export class Rsdkv4SDK extends EngineSDKBase<Rsdkv4Payloads> {
1435
- static readonly id = 'rsdkv4';
1516
+ static readonly id = ENGINE_ID;
1436
1517
  /** Kept in step with package.json by `node scripts/sync-version.mjs`. */
1437
- static readonly version = '0.2.1';
1518
+ static readonly version = '0.3.0';
1438
1519
  static readonly name = 'Retro Software Development Kit v4';
1439
1520
  static readonly description =
1440
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.';