@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.
- package/README.md +159 -213
- package/dist/rsdkv4/app/app.css +160 -0
- package/dist/rsdkv4/app/app.js +521 -0
- package/dist/rsdkv4/app/assets/player-knuckles.svg +757 -0
- package/dist/rsdkv4/app/assets/player-sonic.svg +720 -0
- package/dist/rsdkv4/app/assets/player-sonictails.svg +1414 -0
- package/dist/rsdkv4/app/assets/player-tails.svg +979 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/special.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-1.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-2.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-3.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-4.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-5.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-6.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic1/zone-7.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/special.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-1.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-10.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-2.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-3.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-4.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-5.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-6.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-7.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-8.png +0 -0
- package/dist/rsdkv4/app/assets/stages-sonic2/zone-9.png +0 -0
- package/dist/rsdkv4/engine-specs.js +382 -0
- package/dist/rsdkv4/rsdkv4.options.d.ts +8 -5
- package/dist/rsdkv4/rsdkv4.options.d.ts.map +1 -1
- package/dist/rsdkv4/rsdkv4.options.js +1 -1
- package/dist/rsdkv4/rsdkv4.options.js.map +1 -1
- package/dist/rsdkv4/rsdkv4.sdk.d.ts +24 -2
- package/dist/rsdkv4/rsdkv4.sdk.d.ts.map +1 -1
- package/dist/rsdkv4/rsdkv4.sdk.js +104 -26
- package/dist/rsdkv4/rsdkv4.sdk.js.map +1 -1
- package/package.json +7 -4
- package/src/app/app.css +160 -0
- package/src/app/app.js +521 -0
- package/src/rsdkv4.options.ts +9 -6
- 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: '
|
|
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
|
-
/**
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
1116
|
-
|
|
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.#
|
|
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.#
|
|
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
|
|
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.#
|
|
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 =
|
|
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.
|
|
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.';
|