@wasm-gaming/rsdkv4-wasm 0.1.4 → 0.2.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 CHANGED
@@ -54,16 +54,77 @@ The launcher decides how Escape behaves and draws its own overlay. It should
54
54
  intercept Escape in the **capture phase** if it wants to repurpose it (the engine
55
55
  ships with `DevMenu=false`, so the native in-canvas menu stays suppressed).
56
56
 
57
+ > ⚠️ A capture-phase listener on `window` that calls `stopPropagation()` also
58
+ > stops the **bubble**-phase listener SDL registers there — i.e. it takes the
59
+ > keyboard away from the game. Swallow only the keys you actually handle, and
60
+ > only while your overlay is open. (This is exactly how the shared demo
61
+ > template's pause menu used to kill the d-pad; see
62
+ > [src/demo/components/esc-menu.html](src/demo/components/esc-menu.html).)
63
+
64
+ ### Start menu — the host can own that too
65
+ `instance.game` exposes RSDKv4's Start Menu as data (the `web_*` embind functions
66
+ in `WebGame.cpp`), so a host can draw its own save / character / options screens
67
+ instead of the engine's in-canvas text menus. The logic mirrors
68
+ `Debug.cpp` move for move — same globals, same `saveRAM`, same
69
+ `InitStartingStage()` — so a game started this way lands in the state the native
70
+ menu would have produced.
71
+
72
+ ```js
73
+ const engine = await load({ canvas, assets: { data },
74
+ options: { skipStartMenu: true }, // hide the engine's own screens
75
+ });
76
+
77
+ engine.game.type(); // 1 = Sonic 1, 2 = Sonic 2
78
+ engine.game.players(); // ["SONIC", "TAILS", "KNUCKLES", "SONIC AND TAILS"]
79
+ engine.game.saveSlots(); // [{ slot, empty, character, lives, score, emeralds, list, zone }]
80
+ engine.game.start(0, 1); // slot 0 as Tails — a slot with data resumes it
81
+ engine.game.start(null, 0); // no-save mode
82
+ engine.game.deleteSave(2);
83
+
84
+ engine.game.options(); // the engine's GAME OPTIONS screen, as data
85
+ engine.game.setOption('spindash', 1); // written through to the game's save file
86
+ ```
87
+
88
+ `load()` resolves only once `Engine::Init` has run, so all of the above is safe to
89
+ call the moment it returns. The demo's [start-screens.ts](src/demo/start-screens.ts)
90
+ is a worked example.
91
+
92
+ ### Pausing has an owner
93
+ `pause(owner?)` / `resume(owner?)` — the **first** caller to pause owns it, and only
94
+ that owner's `resume()` lifts it. A pause overlay therefore can't resume a game a
95
+ start screen (or the host) had already frozen:
96
+
97
+ ```js
98
+ engine.pause('menu'); // overlay opens
99
+ engine.resume('menu'); // …closes: only resumes if 'menu' was the one that paused
100
+ ```
101
+
102
+ Pausing stops the **audio** too: `masterPaused` only freezes the logic loop, so
103
+ without that the music kept playing over the overlay — and `destroy()` kept
104
+ playing it after the host returned to its launcher.
105
+
57
106
  ### Options
58
107
  Engine-specific options are described in [src/rsdkv4.options.ts](src/rsdkv4.options.ts)
59
108
  (`Rsdkv4Options` type + `RSDKV4_OPTIONS_SCHEMA`, mirrored into
60
109
  [src/rsdkv4.manifest.ts](src/rsdkv4.manifest.ts)'s `options`). When the host doesn't pass an explicit
61
110
  `settings` asset, the SDK **generates `settings.ini` from `config.options`**
62
- (`devMenu`, `engineDebugMode`, `vsync`, `startingCategory/Scene/Player`).
111
+ (`devMenu`, `engineDebugMode`, `vsync`, `skipStartMenu`,
112
+ `startingCategory/Scene/Player`).
113
+
114
+ > `startingCategory/Scene/Player` default to **255 = unset**, which is what the
115
+ > engine reads as "boot normally". Any other value — *including 0* — makes
116
+ > `Engine::Init` skip the start-menu flow and force that stage.
63
117
 
64
118
  ### Other niceties
65
119
  - **Canvas id guard** — forces `canvas.id = "canvas"` because Emscripten's SDL2
66
120
  port locates the canvas via `querySelector('#canvas')`.
121
+ - **Stale-engine self-heal** — `rsdkv4.js`/`.wasm` are big and most static hosts
122
+ send nothing but `Last-Modified`, so browsers keep a build for hours. Fresh SDK
123
+ against a cached engine is a miserable failure mode: the bridges it calls
124
+ aren't there and every getter quietly answers "empty" — no save slots, no
125
+ characters, an empty pause menu. So `load()` checks the module for the bridges
126
+ it needs and, if any are missing, refetches once with a cache-busting query and
127
+ says so in the console. A healthy build loads the engine exactly once.
67
128
 
68
129
  > Audio autoplay-unlock and the gamepad→keyboard translator are cross-cutting host
69
130
  > concerns (the app's shared input script); the SDK only exposes the `input`
@@ -73,14 +134,19 @@ Engine-specific options are described in [src/rsdkv4.options.ts](src/rsdkv4.opti
73
134
 
74
135
  Built with **`-sWASMFS`** — Emscripten's modern filesystem, replacing MEMFS. The
75
136
  game working dir `/data` is mounted on **OPFS** (persistent) via the `WebFS.cpp`
76
- `web_mount_opfs` helper when the page is **cross-origin isolated**; otherwise it
77
- falls back to the WASMFS in-memory backend so the engine still boots. `Data.rsdk`
137
+ `web_mount_opfs` helper when the page is **cross-origin isolated** *and* the wasm
138
+ build can create the OPFS backend from the main thread (see the caveat below —
139
+ today's build can't); otherwise it falls back to the WASMFS in-memory backend so
140
+ the engine still boots. `Data.rsdk`
78
141
  and the generated `settings.ini` are written there and read via CWD (the engine
79
142
  is booted with the `UsingCWD` arg).
80
143
 
81
144
  ### Backend selection — `config.persist`
82
- - `undefined` (default) — **auto**: OPFS when cross-origin isolated, else in-memory.
83
- - `'opfs'` — **force** OPFS; warn + fall back to in-memory if unavailable.
145
+ - `undefined` (default) — **auto**: OPFS when cross-origin isolated *and* the build
146
+ supports the mount, else in-memory.
147
+ - `'opfs'` — **ask for** OPFS; warn + fall back to in-memory if unavailable. (It
148
+ can't be *forced*: a build that can't mount OPFS would abort the module, so the
149
+ SDK checks first and falls back rather than trying.)
84
150
  - `null` — **force the in-memory** WASMFS backend (the MEMFS-equivalent fallback;
85
151
  non-persistent).
86
152
 
@@ -95,6 +161,32 @@ is booted with the `UsingCWD` arg).
95
161
  This prevents cross-game reuse collisions and allows purging one game's files
96
162
  without touching the others.
97
163
 
164
+ ### `/data` *is* the OPFS root
165
+ `web_mount_opfs` mounts the backend returned by `wasmfs_create_opfs_backend()`,
166
+ whose root is `navigator.storage.getDirectory()`. So the WASM path `/data/<ns>/…`
167
+ and the OPFS path `<ns>/…` a host reaches from JS are **the same file**:
168
+
169
+ | host, from JS (OPFS API) | engine, inside WASMFS |
170
+ | ---------------------------- | ----------------------------- |
171
+ | `rsdkv4/Sonic1/Data.rsdk` | `/data/rsdkv4/Sonic1/Data.rsdk` |
172
+
173
+ A launcher can therefore write a player's `Data.rsdk` straight into the folder the
174
+ engine will use as its working dir and then load with
175
+ `storageNamespace: 'rsdkv4/Sonic1'` and **no `assets.data` at all** — the SDK finds
176
+ the file already persisted and skips the copy. That is what this repo's demo does
177
+ (see [Try it locally](#try-it-locally)); it keeps one copy of a ~40 MB pack instead
178
+ of two, and the game's save data lands next to it in the same folder.
179
+
180
+ ### Save data survives even without the mount
181
+ The engine writes its save file (`SData.bin`) into the working dir, which is
182
+ in-memory on every build that can't mount OPFS — so game saves would die with the
183
+ page and every slot would read "NEW GAME" forever. The *host* side of OPFS has no
184
+ such limitation (the plain async API works on any main thread), so the SDK
185
+ mirrors that one file itself: it copies any stored `SData.bin` into the working
186
+ dir before the engine boots, and copies it back out on pause, on `destroy()`, and
187
+ every 10s in between. Saves therefore persist per `storageNamespace` even while
188
+ the working dir does not. `persist: null` turns the mirror off with everything else.
189
+
98
190
  ### Skip re-fetch when already persisted
99
191
  When the working dir is OPFS-backed, the SDK checks whether `/data/Data.rsdk`
100
192
  (and `settings.ini`) already exist and **reuses them instead of re-downloading**.
@@ -126,15 +218,20 @@ const purged = sonic2.purgeStorage();
126
218
  console.log(purged); // { data: true|false, settings: true|false }
127
219
  ```
128
220
 
129
- > Persistence only actually engages under cross-origin isolation (see the OPFS
130
- > caveat below), so the skip-fetch benefit needs COOP/COEP + `-pthread` to be real.
131
-
132
- > ⚠️ **OPFS caveat (unverified):** OPFS sync-access handles only exist in Workers,
133
- > so Emscripten proxies them to a thread — reliable OPFS needs **`-pthread`** in
134
- > the build plus **COOP/COEP** headers on the host. This build is single-threaded,
135
- > so today OPFS persistence is best-effort and gated on `crossOriginIsolated`.
136
- > Adding `-pthread` interacts with SDL2 + `emscripten_set_main_loop` and must be
137
- > validated with a real build.
221
+ > Persistence doesn't engage with the current build (see the OPFS caveat below),
222
+ > so the skip-fetch benefit isn't real yet — every load fetches.
223
+
224
+ > ⚠️ **OPFS caveat:** OPFS sync-access handles only exist in Workers, so WASMFS's
225
+ > OPFS backend spawns a proxy worker — and it **refuses to do that from the main
226
+ > browser thread** unless the build has **`-sASYNCIFY`** or **`-sJSPI`**. It
227
+ > doesn't fail softly either: `wasmfs_create_opfs_backend()` asserts, and the
228
+ > failed assert `abort()`s the module, so there is nothing left to fall back with.
229
+ > The SDK mounts by calling into the module from the page (main thread) and this
230
+ > build has neither flag, so `web_opfs_supported()` (WebFS.cpp) reports 0 and the
231
+ > SDK never attempts the mount — COOP/COEP alone changes nothing. Making it real
232
+ > means either building with Asyncify/JSPI, or `-pthread` + performing the mount
233
+ > from the engine thread; both interact with SDL2 + `emscripten_set_main_loop` and
234
+ > must be validated with a real build.
138
235
 
139
236
  ## Build
140
237
 
@@ -149,7 +246,9 @@ make build-wasm # local: runs scripts/build.sh inside emscripten/emsdk (Docker
149
246
 
150
247
  - `build-lib` compiles the SDK/options/manifest (`.js` + `.d.ts`) → `dist/rsdkv4/`.
151
248
  - `build-manifest` serializes the typed manifest to `dist/manifest.json`.
152
- - `build-demo` compiles `src/demo/demo.ts` → `dist/demo.js`, copies
249
+ - `build-demo` compiles `src/demo/{demo,library}.ts` → `dist/`, copies the shared
250
+ demo template from `@wasm-gaming/engine-specs` → `dist/demo/`, overwrites its
251
+ launcher with `src/demo/components/launcher.html` (the two-game one), copies
153
252
  `src/demo/index.html` → `dist/index.html`, seeds `dist/settings.ini`.
154
253
  - **`scripts/build.sh`** does not call Docker — it runs the WASM build steps
155
254
  directly and expects an Emscripten SDK on PATH. In CI it runs inside an
@@ -176,40 +275,148 @@ dist/
176
275
  │ └── rsdkv4.manifest.js(+ .d.ts)
177
276
  ├── manifest.json # declarative manifest (artifacts → rsdkv4/rsdkv4.*)
178
277
  ├── index.html # demo shell (import map → ./rsdkv4/rsdkv4.sdk.js)
179
- ├── demo.js
278
+ ├── demo.js # window.SDK + window.rsdkv4Library
279
+ ├── library.js # the two-game OPFS library (src/demo/library.ts)
280
+ ├── start-screens.js # save-select screen (src/demo/start-screens.ts)
281
+ ├── assets/ # demo artwork (game logos), from src/demo/assets/
282
+ ├── demo/ # shared template (engine-specs); launcher.html and
283
+ │ # esc-menu.html replaced by our own copies
180
284
  ├── settings.ini # seeded from src/settings.default.ini if absent
181
- └── Data.rsdk # dev-provided (git-ignored)
285
+ └── Data.rsdk # unused by the demo; kept for manual experiments
182
286
  ```
183
287
 
184
288
  ## Try it locally
185
289
 
186
290
  ```bash
187
- make build # produce dist/ (TypeScript + WASM)
188
- cp /path/to/Data.rsdk dist/ # git-ignored dev file the demo auto-loads
189
- cp /path/to/settings.ini dist/ # OPTIONAL — else seeded/generated
190
- make preview # serves dist/ on :8080
191
- # open http://localhost:8080/
291
+ make build # produce dist/ (TypeScript + WASM)
292
+ make preview # serves dist/ on :8024 with COOP/COEP
293
+ # open http://localhost:8024/
294
+ ```
295
+
296
+ ### The launcher is a two-game library
297
+
298
+ One WASM, two games — so the demo launcher is not a ROM picker but a shelf with
299
+ one slot per game. Drop **Sonic 1's** `Data.rsdk` on one slot and **Sonic 2's** on
300
+ the other (drag-and-drop or the file picker), pick a slot, press **Play**. Each
301
+ pack is stored once, in the browser, and never uploaded:
302
+
303
+ ```
304
+ OPFS root
305
+ └── rsdkv4/
306
+ ├── Sonic1/
307
+ │ ├── Data.rsdk ← what you dropped
308
+ │ ├── settings.ini ← written by the SDK on first launch
309
+ │ └── … ← that game's save data
310
+ └── Sonic2/…
192
311
  ```
193
312
 
194
- The demo fetches `./Data.rsdk` (required) and, if present, `./settings.ini`
195
- (optional) from `dist/`, mounting both into WASMFS. Without a `Data.rsdk` file the
196
- demo shows a pick / drag-and-drop prompt; without a `settings.ini` file the SDK
197
- synthesizes one from `config.options`.
313
+ Because [`/data` is the OPFS root](#data-is-the-opfs-root), that folder *is* the
314
+ engine's working dir: launching loads with `storageNamespace: 'rsdkv4/Sonic1'` and
315
+ no assets, so nothing is copied or re-read. **Replace** swaps the pack; **Remove**
316
+ deletes only `Data.rsdk`, leaving settings and saves for when it comes back.
317
+
318
+ A page that isn't cross-origin isolated can't reach OPFS from WASM (see the caveat
319
+ above), so the SDK falls back to an in-memory working dir and pulls the bytes
320
+ through `dataProvider()` — the library serves them from the same stored copy, so
321
+ the games still launch. Saves still survive, through the
322
+ [save-data mirror](#save-data-survives-even-without-the-mount).
323
+
324
+ ### The game's own menus, in HTML
325
+
326
+ Launching goes straight to a **save-select screen** — the demo's, not the engine's
327
+ (it boots with `skipStartMenu: true`). It is laid out like Sonic Mania's: a row of
328
+ portrait cards, status on top, character in the middle, chaos emeralds along the
329
+ bottom, `NO SAVE` first. File and character are the *same* choice: **←/→ picks the
330
+ file, ↑/↓ picks the player** (Sonic, Tails, Knuckles, Sonic & Tails) on any unused
331
+ file, Enter starts. A used file starts as the character it was saved with. Everything but the game logo
332
+ (`src/demo/assets/`) is drawn in CSS, and it drives the engine through
333
+ `instance.game`, so the resulting game is the same one the native text menus
334
+ would have started.
335
+
336
+ **Escape** opens the pause overlay, which pauses the engine *and its music* and
337
+ shows only what RSDKv4 can actually do live:
338
+
339
+ - **Jump to** — buttons for the title screen, the game's stage menu, level select
340
+ and special stages 1-8. Each closes the overlay and warps straight away, so
341
+ there is no "now press resume" step
342
+ - **Game options** — the engine's own GAME OPTIONS (spindash, speed caps, S1
343
+ spikes, item box set, super forms), read and written through the same globals
344
+ and save file the native screen uses
345
+
346
+ The pieces: [src/demo/library.ts](src/demo/library.ts) (storage),
347
+ [src/demo/start-screens.ts](src/demo/start-screens.ts) (save select),
348
+ [src/demo/components/launcher.html](src/demo/components/launcher.html) (the slots),
349
+ [src/demo/components/esc-menu.html](src/demo/components/esc-menu.html) (pause overlay),
350
+ [src/demo/index.html](src/demo/index.html) (wiring to `SDK.load`).
198
351
 
199
352
  ## Live demo (GitHub Pages)
200
353
 
201
354
  The `pages` job in [.github/workflows/build.yml](.github/workflows/build.yml)
202
- publishes `dist/` to GitHub Pages on pushes to `main` (and manual runs). Since
203
- `Data.rsdk` is git-ignored, the live demo opens on the pick / drag-and-drop
204
- prompt — drop a `Data.rsdk` to boot.
355
+ publishes `dist/` to GitHub Pages on pushes to `main` (and manual runs). No game
356
+ data ships with it, so the live demo opens on two empty slots — drop each game's
357
+ `Data.rsdk` once and they stay in that browser.
205
358
 
206
359
  - **One-time setup:** repo *Settings → Pages → Build and deployment → Source =
207
360
  GitHub Actions*. The workflow also calls `actions/configure-pages` with
208
361
  `enablement: true`, so first deploy can bootstrap Pages automatically when the
209
362
  repo-level Pages site does not exist yet.
210
- - **OPFS note:** GitHub Pages can't set COOP/COEP headers, so the page isn't
211
- cross-origin isolated — OPFS stays off and the SDK uses the in-memory WASMFS
212
- backend (see the filesystem caveat above).
363
+ - **OPFS note:** OPFS stays off and the SDK uses the in-memory WASMFS backend —
364
+ GitHub Pages can't set COOP/COEP headers, and this build couldn't mount OPFS
365
+ even isolated (see the filesystem caveat above).
366
+
367
+ ## Project Specs
368
+
369
+ What this project is, and the constraints that shape it.
370
+
371
+ - **A contract implementation, not a game.** The deliverable is one game-agnostic
372
+ `rsdkv4.wasm` plus an SDK conforming to
373
+ [@wasm-gaming/engine-specs](https://github.com/wasm-gaming/engine-specs) — `manifest`
374
+ (Layer A) and `load()` (Layer B). A host drives this engine exactly as it drives the
375
+ other eleven.
376
+ - **Ships none of the game.** The player provides `Data.rsdk`; it never leaves the
377
+ browser and is never committed. Which pack the host hands over is what selects Sonic 1
378
+ or Sonic 2.
379
+ - **The engine's own values are the SDK's only private vocabulary.** They live in
380
+ [src/rsdkv4.options.ts](src/rsdkv4.options.ts) — seven keys, mirrored as JSON Schema in
381
+ `manifest.options` — and in the extra fields of `Rsdkv4LoadConfig`. Everything else is
382
+ contract, documented once at the [contract
383
+ reference](https://wasm-gaming.github.io/engine-specs/api-docs/).
384
+ - **The host owns the files; the SDK owns its folder.** The SDK reads the pack and never
385
+ writes it. What it does write — save data, `settings.ini` — lives under the host-chosen
386
+ `storageNamespace`.
387
+ - **Persistence needs cross-origin isolation.** OPFS sync access exists only in workers,
388
+ so the working dir falls back to in-memory WASMFS when the page isn't isolated (or when
389
+ the build can't create the backend from the main thread — see the filesystem caveat).
390
+ - **Everything builds from the Makefile**, `package.json` has no scripts. `dist/` is both
391
+ the npm artifact and the Pages site: demo at the root, `/craft`, `/vanilla`, and the API
392
+ reference at `/api-docs`.
393
+ - **Sessions are the record.** Specs and decisions live in [SESSIONS/](SESSIONS/), append-only.
394
+ - **Where it is going:** contract 1.0 replaces `{ manifest, load }` with `EngineSDK` /
395
+ `EnginePlay` classes. The six `docs/{SDK,PLAY}-*.md` documents describe that API for this
396
+ engine, and the migration plan is
397
+ [SESSIONS/2026-08-14_12h35.sdk-migration-to-contract-1.0.session.md](SESSIONS/2026-08-14_12h35.sdk-migration-to-contract-1.0.session.md).
398
+
399
+ ## Todos
400
+
401
+ Contract 1.0 migration — blocked on `engine-specs` unless noted:
402
+
403
+ - [ ] **Worker smoke test** (not blocked, do first): `-pthread` + `-sPROXY_TO_PTHREAD` +
404
+ `OffscreenCanvas`, then measure audio (SDL's callback decodes Ogg straight from the
405
+ pack) and input latency. The async 1.0 API rests on this.
406
+ - [ ] Split `Rsdkv4Options` into `Rsdkv4Config` (engine) and `Rsdkv4Options` (session).
407
+ - [ ] Manifest to 1.0: `contractVersion`, `config`, no `mountPath`, `capabilities.saves`.
408
+ - [ ] Parse `SData.bin` from JS, so `sdk.saves()` reads slots without booting the wasm.
409
+ - [ ] `Rsdkv4Play` / `Rsdkv4SDK` classes; `create()` as the only engine-specific method.
410
+ - [ ] Migrate the three hosts: `src/vanilla`, `src/craft`, `src/demo` (~65 call sites).
411
+
412
+ Independent of the migration:
413
+
414
+ - [ ] `export default { manifest, load } satisfies EngineSDK` — nothing declares conformance today.
415
+ - [ ] Emit `{ type: 'exit' }` at the end of `destroy()`.
416
+ - [ ] Don't emit `ready` when the engine never came up: today `waitForEngine()` times out
417
+ after 4 s, warns, and reports success anyway.
418
+ - [ ] `assertManifest(manifest)` in [scripts/emit-manifest.mjs](scripts/emit-manifest.mjs).
419
+ - [ ] End-to-end browser run from a real `Data.rsdk`.
213
420
 
214
421
  ## Status
215
422
 
@@ -219,9 +426,9 @@ prompt — drop a `Data.rsdk` to boot.
219
426
  the `web_devmenu_*` embind bridge, `callMain`/`FS`/`ccall`, no `--preload-file`).
220
427
  - ⏳ **End-to-end runtime in a browser** (boot from a real `Data.rsdk`) — not yet
221
428
  verified.
222
- - ⏳ **OPFS persistence** — gated on `crossOriginIsolated`; almost certainly needs
223
- a `-pthread` build + COOP/COEP to actually engage (see the caveat above). Until
224
- then the SDK falls back to the in-memory WASMFS backend.
429
+ - ⏳ **OPFS persistence** — not engaged: WASMFS's OPFS backend can't be created on
430
+ the main browser thread without an Asyncify/JSPI build (see the caveat above),
431
+ so the SDK detects that and falls back to the in-memory WASMFS backend.
225
432
 
226
433
  ## License
227
434