@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
package/README.md CHANGED
@@ -10,228 +10,170 @@ into the Emscripten filesystem *at runtime*, so the same `rsdkv4.wasm` runs both
10
10
  is what picks the game. (The old build baked `Data.rsdk` in with `--preload-file`
11
11
  and had to be recompiled per game; this doesn't.)
12
12
 
13
- ## Contract surface
13
+ ## Install
14
14
 
15
- ```js
16
- import { manifest, load } from '@wasm-gaming/rsdkv4-wasm';
17
-
18
- const engine = await load({
19
- canvas, // an <canvas id="canvas">
20
- assets: { data: dataRsdkBytes /*, settings: iniBytes */ },
21
- storageNamespace: 'sonic1', // carpeta por juego bajo /data (OPFS/WASMFS)
22
- onEvent: (e) => { /* 'ready' | 'error' | 'exit' */ },
23
- // Where the app placed the CI-built artifacts (defaults to package-relative):
24
- // jsUrl, wasmUrl,
25
- });
26
-
27
- engine.pause();
28
- engine.resume();
29
- engine.setInput('rsdkv4');
30
- engine.purgeStorage(); // borra Data.rsdk/settings.ini de este namespace
31
- engine.destroy();
15
+ ```bash
16
+ npm install @wasm-gaming/rsdkv4-wasm
32
17
  ```
33
18
 
34
- - `manifest` — declarative `EngineManifest` (see [src/rsdkv4.manifest.ts](src/rsdkv4.manifest.ts)):
35
- required `data` asset (`/Data.rsdk`), optional `settings` asset (`/settings.ini`),
36
- `input: "rsdkv4"` preset, 16:9 video, no save-states.
37
- - `load(config)` → `EngineInstance` (`start`/`pause`/`resume`/`reset`/`setInput`/`destroy`).
38
- `reset()` is unsupported by RSDKv4 — `destroy()` and `load()` again.
19
+ One dependency comes with it: `@wasm-gaming/engine-specs`, the engine contract. The
20
+ built SDK carries the contract's runtime beside it (`dist/rsdkv4/engine-specs.js`) and
21
+ imports it relatively, so the package loads from any static server — **no bundler and
22
+ no import map needed**. Serve `dist/rsdkv4/` as is and import `rsdkv4.sdk.js`.
23
+
24
+ ## Contract surface
39
25
 
40
- ### Dev menu — the launcher owns the UI
41
- The debug/stage-select **UI is the launcher's responsibility**, not the engine's.
42
- The engine only exposes the raw bridge as `instance.devMenu` (backed by the
43
- `web_devmenu_*` embind functions compiled into the WASM):
26
+ The SDK implements the [wasm-gaming engine contract](https://github.com/wasm-gaming/engine-specs)
27
+ 1.0: a class with a static identity, configured by chaining, that opens a live session.
44
28
 
45
29
  ```js
46
- const engine = await load({ canvas, assets: { data } });
47
- engine.pause(); // freeze while a menu is open
48
- const categories = engine.devMenu.getStageList(); // [{ name, stages: [{ name }] }]
49
- engine.devMenu.loadStage(0, 2); // warp to category 0 / stage 2
50
- engine.resume();
30
+ import Rsdkv4SDK from '@wasm-gaming/rsdkv4-wasm';
31
+
32
+ Rsdkv4SDK.id; // 'rsdkv4' — also this engine's folder in OPFS
33
+ Rsdkv4SDK.version; // '0.3.0'
34
+
35
+ const play = await new Rsdkv4SDK()
36
+ .config({ startMenu: 'host' }) // engine: who draws the start screens
37
+ .assets({ data: () => fetchDataRsdk() }) // Data.rsdk — bytes, or a thunk called only on a miss
38
+ .storage({ namespace: 'sonic-1' }) // → OPFS rsdkv4/sonic-1/
39
+ .on('saves', ({ detail }) => paintSlots(detail))
40
+ .on('error', ({ detail }) => report(detail))
41
+ .mount(document.querySelector('#stage')) // a <canvas> is used as is; anything else gets one inside
42
+ .start({ slot: 0, player: 1 }); // session: slot 0 as Tails
43
+
44
+ await play.restart({ slot: 2 });
45
+ await play.destroy(); // stops audio, flushes the save, releases the canvas
51
46
  ```
52
47
 
53
- The launcher decides how Escape behaves and draws its own overlay. It should
54
- intercept Escape in the **capture phase** if it wants to repurpose it (the engine
55
- ships with `DevMenu=false`, so the native in-canvas menu stays suppressed).
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.
48
+ The four payloads, each read with `await sdk.config()` (a descriptor per key: value,
49
+ default, enum, description, `live`) and written with `sdk.config({ … })`:
71
50
 
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
- ```
51
+ | payload | keys |
52
+ | --- | --- |
53
+ | `config` (engine) | `startMenu` (`'native'` \| `'host'`), `devMenu`, `engineDebugMode`, `vsync`, `jsUrl`, `wasmUrl` |
54
+ | `options` (session) | `slot`, `player`, `stage`, `buttons` |
55
+ | `assets` | `data` (Data.rsdk, required), `settings` (settings.ini, optional — generated from `config` otherwise) |
56
+ | `storage` | `namespace`, `persist` (`'opfs'` \| `null`) |
87
57
 
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.
58
+ Types: `Rsdkv4Config`, `Rsdkv4Options`, `Rsdkv4Assets`, `Rsdkv4Storage` and the full
59
+ spec as data in `RSDKV4_SPEC` ([src/rsdkv4.options.ts](src/rsdkv4.options.ts)). CI holds
60
+ the class to the contract with one line: `RSDKV4_ENGINE: EngineSDKConstructor<Rsdkv4Payloads>`.
91
61
 
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:
62
+ Events: the contract's `start`, `error`, `exit`, `pause`, `resume`, plus `saves`
63
+ (the slots, emitted when `SData.bin` changed and was persisted).
64
+
65
+ `jsUrl` / `wasmUrl` default to `rsdkv4.js` / `rsdkv4.wasm` next to `rsdkv4.sdk.js`; a
66
+ host that serves them elsewhere sets them in `config`.
67
+
68
+ ### Start menu — the host can own it
69
+
70
+ With `config({ startMenu: 'host' })` the engine's own save / character / options screens
71
+ are skipped and `play.game` exposes them as data (the `web_*` embind functions in
72
+ `WebGame.cpp`, mirroring `Debug.cpp` move for move, so a game started this way lands in
73
+ the state the native menu would have produced):
96
74
 
97
75
  ```js
98
- engine.pause('menu'); // overlay opens
99
- engine.resume('menu'); // …closes: only resumes if 'menu' was the one that paused
76
+ play.game.type(); // 1 = Sonic 1, 2 = Sonic 2
77
+ play.game.players(); // ["SONIC", "TAILS", "KNUCKLES", "SONIC AND TAILS"]
78
+ play.game.saveSlots(); // [{ slot, empty, character, lives, score, emeralds, list, zone }]
79
+ play.game.start(0, 1); // slot 0 as Tails — a slot with data resumes it
80
+ play.game.start(null, 0); // no-save mode
81
+ play.game.deleteSave(2);
82
+ play.game.options(); // the GAME OPTIONS screen, as data
83
+ play.game.setOption('spindash', 1);
100
84
  ```
101
85
 
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.
86
+ `start()` resolves only once `Engine::Init` has run, so all of the above is safe to call
87
+ the moment it does.
88
+
89
+ ### Dev menu — the host owns the UI
105
90
 
106
- ### Options
107
- Engine-specific options are described in [src/rsdkv4.options.ts](src/rsdkv4.options.ts)
108
- (`Rsdkv4Options` type + `RSDKV4_OPTIONS_SCHEMA`, mirrored into
109
- [src/rsdkv4.manifest.ts](src/rsdkv4.manifest.ts)'s `options`). When the host doesn't pass an explicit
110
- `settings` asset, the SDK **generates `settings.ini` from `config.options`**
111
- (`devMenu`, `engineDebugMode`, `vsync`, `skipStartMenu`,
112
- `startingCategory/Scene/Player`).
91
+ The stage-select **UI is the host's**; the engine exposes the bridge as `play.devMenu`
92
+ (`getStageList()`, `loadStage(category, stage)`, `setPaused()`, `open()`). The engine
93
+ ships with `devMenu: false`, so the native in-canvas menu stays suppressed.
113
94
 
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.
95
+ > ⚠️ A capture-phase listener on `window` that calls `stopPropagation()` also stops the
96
+ > bubble-phase listener SDL registers there — i.e. it takes the keyboard away from the
97
+ > game. Swallow only the keys you handle, and only while your overlay is open.
98
+
99
+ ### Pausing has an owner
100
+
101
+ `play.pause(owner?)` / `play.resume(owner?)` — the **first** caller to pause owns it,
102
+ and only that owner's `resume()` lifts it, so a pause overlay can't resume a game a
103
+ start screen had already frozen. Pausing stops the audio too.
104
+
105
+ ### Input
106
+
107
+ `play.input.claim(buttons)` / `play.input.release()` (same as `options({ buttons })`)
108
+ let a host drive the fourteen buttons instead of the engine reading the keyboard.
117
109
 
118
110
  ### Other niceties
119
- - **Canvas id guard** — forces `canvas.id = "canvas"` because Emscripten's SDL2
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.
128
-
129
- > Audio autoplay-unlock and the gamepad→keyboard translator are cross-cutting host
130
- > concerns (the app's shared input script); the SDK only exposes the `input`
131
- > preset name via the manifest and `setInput()`.
111
+
112
+ - **Canvas id guard** — Emscripten's SDL2 port finds the canvas with
113
+ `querySelector('#canvas')`, so the SDK gives the canvas that id.
114
+ - **Stale-engine self-heal** — fresh SDK against a browser-cached older wasm would make
115
+ every getter answer "empty". The SDK checks the module for the bridges it needs and,
116
+ if any are missing, refetches once past the HTTP cache and says so in the console.
132
117
 
133
118
  ## Filesystem (WASMFS / OPFS)
134
119
 
135
- Built with **`-sWASMFS`** — Emscripten's modern filesystem, replacing MEMFS. The
136
- game working dir `/data` is mounted on **OPFS** (persistent) via the `WebFS.cpp`
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`
141
- and the generated `settings.ini` are written there and read via CWD (the engine
142
- is booted with the `UsingCWD` arg).
120
+ Built with **`-sWASMFS`**. WASMFS `/data` is the OPFS root, mounted when the page is
121
+ cross-origin isolated *and* the build can create the OPFS backend from the main thread;
122
+ otherwise it falls back to the in-memory backend so the engine still boots.
123
+
124
+ ### Every file lives in the engine's own folder
125
+
126
+ ```
127
+ OPFS root (= WASMFS /data)
128
+ └── rsdkv4/ ← Rsdkv4SDK.id, added by the SDK
129
+ └── <namespace>/ ← storage({ namespace }), default "default"
130
+ ├── Data.rsdk
131
+ ├── settings.ini
132
+ └── SData.bin
133
+ ```
134
+
135
+ The host picks the namespace; **the engine owns the root**. No namespace can reach
136
+ another engine's files or climb out of `rsdkv4/`: segments are cleaned (`.` and `..`
137
+ dropped, characters outside `[A-Za-z0-9._-]` → `_`), and anything unusable becomes
138
+ `default`. `play.storagePath` gives the resolved path (`'rsdkv4/sonic-1'`), which is
139
+ also the OPFS path a host walks with `navigator.storage.getDirectory()`:
140
+
141
+ | host, from JS (OPFS API) | engine, inside WASMFS |
142
+ | ------------------------------- | ---------------------------------- |
143
+ | `rsdkv4/sonic-1/Data.rsdk` | `/data/rsdkv4/sonic-1/Data.rsdk` |
144
+
145
+ So a launcher can **install** a game by writing `Data.rsdk` into
146
+ `rsdkv4/<namespace>/` and later start it with `storage({ namespace })` and no
147
+ `assets.data` at all — the SDK finds the persisted copy and skips the copy. One ~40 MB
148
+ pack, not two, and the save lands next to it. `storagePath(engineId, namespace)` is
149
+ exported for hosts that need the path before a session exists.
150
+
151
+ **Upgrading from 0.2.x:** a namespace starting with `rsdkv4/` (the old convention,
152
+ e.g. `'rsdkv4/Sonic1'`) has the prefix stripped once, with a console warning, so its
153
+ files stay where they were — pass `'Sonic1'`. Files from the old top-level `default/`
154
+ folder (`Data.rsdk`, `settings.ini`, `SData.bin`) are **copied** into
155
+ `rsdkv4/default/` the first time the default namespace boots; the old folder is left
156
+ untouched, since other engines used `default/` too.
157
+
158
+ ### Backend selection — `storage.persist`
143
159
 
144
- ### Backend selection — `config.persist`
145
160
  - `undefined` (default) — **auto**: OPFS when cross-origin isolated *and* the build
146
161
  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.)
150
- - `null` — **force the in-memory** WASMFS backend (the MEMFS-equivalent fallback;
151
- non-persistent).
152
-
153
- (`'idbfs'` isn't supported under WASMFS and is treated as in-memory.)
154
-
155
- ### Per-game storage namespace — `config.storageNamespace`
156
- - `undefined` (default) — uses `/data/default`.
157
- - `'sonic1'` / `'sonic2'` (recommended) — keeps each game in its own folder,
158
- e.g. `/data/sonic1` and `/data/sonic2`.
159
- - Supports nested paths like `'sonic1/profile-a'`.
160
-
161
- This prevents cross-game reuse collisions and allows purging one game's files
162
- without touching the others.
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.
162
+ - `'opfs'` — **ask for** OPFS; warn and fall back to in-memory if unavailable.
163
+ - `null` — force in-memory, and switch off the save mirror: nothing is kept.
179
164
 
180
165
  ### 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
166
 
190
- ### Skip re-fetch when already persisted
191
- When the working dir is OPFS-backed, the SDK checks whether `/data/Data.rsdk`
192
- (and `settings.ini`) already exist and **reuses them instead of re-downloading**.
193
- Precedence for `Data.rsdk`: explicit `assets.data` → persisted OPFS copy → lazy
194
- `dataProvider()` (called only on a miss). So a host can pass a `dataProvider` that
195
- fetches the ROM and it will only run on first load / non-isolated pages:
196
-
197
- ```js
198
- await load({
199
- canvas,
200
- assets: {}, // no eager 40 MB download
201
- storageNamespace: 'sonic1', // namespace-specific cache
202
- dataProvider: () => fetch('/Data.rsdk').then(r => r.arrayBuffer()),
203
- });
204
- ```
167
+ On every build that can't mount OPFS the working dir is in-memory, so `SData.bin` would
168
+ die with the page. The host-side OPFS API has no such limitation, so the SDK mirrors that
169
+ one file itself: in before boot, out on pause, on `destroy()`, when the page is hidden,
170
+ and every 10 s — always to `rsdkv4/<namespace>/SData.bin`, and `saves` fires when it moved.
205
171
 
206
- To purge only one game's persisted files:
207
-
208
- ```js
209
- const sonic2 = await load({
210
- canvas,
211
- assets: {},
212
- storageNamespace: 'sonic2',
213
- dataProvider: () => fetch('/sonic2/Data.rsdk').then(r => r.arrayBuffer()),
214
- });
215
-
216
- // Removes /data/sonic2/Data.rsdk and /data/sonic2/settings.ini only.
217
- const purged = sonic2.purgeStorage();
218
- console.log(purged); // { data: true|false, settings: true|false }
219
- ```
172
+ ### Skip re-fetch when already persisted
220
173
 
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.
174
+ Precedence for `Data.rsdk`: explicit bytes in `assets.data` → a copy already in
175
+ `rsdkv4/<namespace>/` → the `assets.data` thunk (called only on a miss). So a host can
176
+ hand over a thunk that downloads the pack and it only runs the first time.
235
177
 
236
178
  ## Build
237
179
 
@@ -244,8 +186,9 @@ make build-sdk # TypeScript only → dist/ (fast; no Docker)
244
186
  make build-wasm # local: runs scripts/build.sh inside emscripten/emsdk (Docker)
245
187
  ```
246
188
 
247
- - `build-lib` compiles the SDK/options/manifest (`.js` + `.d.ts`) → `dist/rsdkv4/`.
248
- - `build-manifest` serializes the typed manifest to `dist/manifest.json`.
189
+ - `build-lib` compiles the SDK and options (`.js` + `.d.ts`) → `dist/rsdkv4/`.
190
+ - `build-specs` copies the contract's runtime to `dist/rsdkv4/engine-specs.js` and rewrites
191
+ the SDK's import to it (`scripts/localize-specs.mjs`), so the package is self-contained.
249
192
  - `build-demo` compiles `src/demo/{demo,library}.ts` → `dist/`, copies the shared
250
193
  demo template from `@wasm-gaming/engine-specs` → `dist/demo/`, overwrites its
251
194
  launcher with `src/demo/components/launcher.html` (the two-game one), copies
@@ -260,8 +203,9 @@ make build-wasm # local: runs scripts/build.sh inside emscripten/emsdk (Docker
260
203
  `-sWASMFS -sINVOKE_RUN=0 -sMODULARIZE=1 -sEXPORT_ES6=1 -sEXPORT_NAME=createRSDKv4`
261
204
  + `FS`/`callMain`/`ccall` exported, **without** `--preload-file`.
262
205
 
263
- Contract types are resolved from the npm package `@wasm-gaming/engine-specs`
264
- (installed via this project's devDependencies).
206
+ The contract is the one dependency: `@wasm-gaming/engine-specs` (types from the package,
207
+ runtime copied into `dist/rsdkv4/`). `make test` type-checks the SDK and runs `tests/`
208
+ against the built package.
265
209
 
266
210
  ### dist/ layout
267
211
 
@@ -272,8 +216,7 @@ dist/
272
216
  │ ├── rsdkv4.wasm
273
217
  │ ├── rsdkv4.sdk.js (+ .d.ts)
274
218
  │ ├── rsdkv4.options.js (+ .d.ts)
275
- │ └── rsdkv4.manifest.js(+ .d.ts)
276
- ├── manifest.json # declarative manifest (artifacts → rsdkv4/rsdkv4.*)
219
+ │ └── engine-specs.js # the contract's runtime, imported relatively by the SDK
277
220
  ├── index.html # demo shell (import map → ./rsdkv4/rsdkv4.sdk.js)
278
221
  ├── demo.js # window.SDK + window.rsdkv4Library
279
222
  ├── library.js # the two-game OPFS library (src/demo/library.ts)
@@ -310,8 +253,8 @@ OPFS root
310
253
  └── Sonic2/…
311
254
  ```
312
255
 
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
256
+ Because [every file lives in the engine's own folder](#every-file-lives-in-the-engines-own-folder),
257
+ that folder *is* the engine's working dir: launching starts with `storage({ namespace: 'Sonic1' })` and
315
258
  no assets, so nothing is copied or re-read. **Replace** swaps the pack; **Remove**
316
259
  deletes only `Data.rsdk`, leaving settings and saves for when it comes back.
317
260
 
@@ -370,31 +313,34 @@ What this project is, and the constraints that shape it.
370
313
 
371
314
  - **A contract implementation, not a game.** The deliverable is one game-agnostic
372
315
  `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.
316
+ [@wasm-gaming/engine-specs](https://github.com/wasm-gaming/engine-specs) 1.0 —
317
+ `Rsdkv4SDK extends EngineSDKBase`. A host drives this engine exactly as it drives the
318
+ others.
319
+ - **Works on its own.** Clone, `npm ci`, `make build-sdk test`: no other wasm-gaming
320
+ repo needed. engine-specs is the one dependency, and its runtime ships inside
321
+ `dist/rsdkv4/`, so the package needs no bundler or import map.
376
322
  - **Ships none of the game.** The player provides `Data.rsdk`; it never leaves the
377
323
  browser and is never committed. Which pack the host hands over is what selects Sonic 1
378
324
  or Sonic 2.
379
325
  - **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
326
+ [src/rsdkv4.options.ts](src/rsdkv4.options.ts) — `RSDKV4_SPEC`, the four payloads as
327
+ data. Everything else is
382
328
  contract, documented once at the [contract
383
329
  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`.
330
+ - **The host owns the files; the SDK owns its folder.** Everything lives in
331
+ `rsdkv4/<namespace>/` (WASMFS and OPFS alike): the engine adds `rsdkv4/`, the host only
332
+ picks `<namespace>`. The SDK reads the pack and never writes it; what it does write —
333
+ save data, `settings.ini` — lands in the same folder.
387
334
  - **Persistence needs cross-origin isolation.** OPFS sync access exists only in workers,
388
335
  so the working dir falls back to in-memory WASMFS when the page isn't isolated (or when
389
336
  the build can't create the backend from the main thread — see the filesystem caveat).
390
337
  - **Everything builds from the Makefile**, `package.json` has no scripts. `dist/` is both
391
338
  the npm artifact and the Pages site: demo at the root, `/craft`, `/vanilla`, and the API
392
339
  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).
340
+ - **RECORD/ is the record.** Specs and decisions live in [RECORD/](RECORD/): `.WIP.md` append-only, `.completed.md` read-only.
341
+ - **The reference engine.** The other wasm-gaming engines migrate to this layout, and the
342
+ [play.germade](https://github.com/jgermade/play.germade) launcher consumes it first — see
343
+ [RECORD/2026-11-12.standalone-package-and-wasmfs-engine-namespace.WIP.md](RECORD/2026-11-12.standalone-package-and-wasmfs-engine-namespace.WIP.md).
398
344
 
399
345
  ## Todos
400
346
 
@@ -0,0 +1,160 @@
1
+ /* rsdkv4 app — the load-game screen (craft's slot cards) and the pause menu.
2
+ Tokens come from engine-specs (UI_TOKENS, `--wg-*`) when the host sets them; the
3
+ fallbacks are the same values, so the app looks right on a bare page too. */
4
+
5
+ .rsdk-app {
6
+ --ring: var(--wg-focus-ring, 0 0 0 3px #fff);
7
+ --surface: var(--wg-surface, rgb(18 20 28 / 0.78));
8
+ --line: var(--wg-line, rgb(255 255 255 / 0.12));
9
+ --text: var(--wg-text, #f4f6fb);
10
+ --muted: var(--wg-muted, #8e97a9);
11
+ --danger: var(--wg-danger, #f87171);
12
+ --ui: var(--wg-font-ui, 'Inter Variable', 'Inter', system-ui, sans-serif);
13
+ --display: var(--wg-font-display, 'Unbounded Variable', 'Unbounded', system-ui, sans-serif);
14
+ --ease: var(--wg-ease, cubic-bezier(0.2, 0.8, 0.2, 1));
15
+ position: absolute;
16
+ inset: 0;
17
+ overflow: hidden;
18
+ background: #000;
19
+ color: var(--text);
20
+ font-family: var(--ui);
21
+ user-select: none;
22
+ -webkit-tap-highlight-color: transparent;
23
+ }
24
+ .rsdk-app * { box-sizing: border-box; }
25
+ .rsdk-stage { position: absolute; inset: 0; display: grid; place-items: center; }
26
+ .rsdk-stage canvas { outline: none; }
27
+ .rsdk-overlay { position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none; }
28
+ .rsdk-overlay > * { pointer-events: auto; }
29
+ .rsdk-app[data-screen='game'] .rsdk-overlay { display: none; }
30
+
31
+ /* Everything but the game sits on a backdrop that keeps the game visible underneath. */
32
+ .rsdk-app:not([data-screen='game']) .rsdk-overlay {
33
+ background:
34
+ radial-gradient(120% 80% at 50% 0%, color-mix(in srgb, var(--accent) 38%, transparent), transparent 70%),
35
+ rgb(4 6 12 / 0.72);
36
+ backdrop-filter: blur(14px) saturate(1.2);
37
+ -webkit-backdrop-filter: blur(14px) saturate(1.2);
38
+ }
39
+ .rsdk-app[data-screen='boot'] .rsdk-overlay,
40
+ .rsdk-app[data-screen='slots'] .rsdk-overlay { background-color: rgb(4 6 12 / 0.9); }
41
+
42
+ .kicker { margin: 0; font: 600 0.72rem var(--ui); letter-spacing: 0.22em; text-transform: uppercase; color: var(--muted); }
43
+ .rsdk-app h2 { margin: 0; font: 700 clamp(1.4rem, 3vw, 2.1rem) var(--display); letter-spacing: -0.02em; }
44
+
45
+ /* ---- boot ---- */
46
+ .rsdk-boot { display: grid; justify-items: center; gap: 22px; }
47
+ .rsdk-boot img { width: min(260px, 50vw); border-radius: 22px; box-shadow: 0 30px 80px -30px #000; animation: rsdk-breathe 2.4s var(--ease) infinite alternate; }
48
+ .rsdk-boot p { margin: 0; color: var(--muted); font-weight: 500; }
49
+ @keyframes rsdk-breathe { from { opacity: 0.55; transform: scale(0.98); } to { opacity: 1; transform: none; } }
50
+
51
+ /* ---- load game: craft's slot cards (src/craft/views/GameLauncher.html) ---- */
52
+ .rsdk-slots { width: min(1120px, 100%); padding: 0 clamp(16px, 4vw, 48px); display: grid; gap: clamp(22px, 4vh, 40px); }
53
+ .rsdk-slots header { display: grid; gap: 6px; }
54
+ .slot-cards {
55
+ --w: clamp(150px, 13vw, 190px);
56
+ display: flex; justify-content: center; align-items: flex-start; gap: clamp(12px, 1.6vw, 24px);
57
+ }
58
+ .slot-card {
59
+ flex: none; width: var(--w); height: calc(var(--w) * 220 / 170);
60
+ display: flex; flex-direction: column; justify-content: space-between; gap: 0.4rem;
61
+ padding: 0.75rem; border-radius: 0.5rem; border: 2px solid #000; background: #333; color: #fff;
62
+ font-family: system-ui, sans-serif;
63
+ transition: transform 0.2s var(--ease), box-shadow 0.2s var(--ease);
64
+ }
65
+ .slot-card.is-focused { box-shadow: var(--ring), 0 24px 50px -20px #000; transform: translateY(-4px); }
66
+ .slot-card.is-nosave {
67
+ height: calc(var(--w) * 130 / 170); justify-content: center; align-items: center;
68
+ font-weight: 700; cursor: pointer;
69
+ }
70
+ .slot-card.is-nosave:hover:not(:active) { background: #222; }
71
+ .slot-card img { display: block; }
72
+
73
+ .slot-play {
74
+ all: unset; box-sizing: border-box; flex-shrink: 0; width: 100%; height: 34px; text-align: center;
75
+ border-radius: 4px; background: royalblue; color: #fff; font-weight: 700; text-shadow: 0 0 2px #000; cursor: pointer;
76
+ }
77
+ .slot-play:hover:not(:active), .slot-card.is-focused .slot-play { background: blue; }
78
+
79
+ /* the character picker: white square, the character over two drifting colour bands */
80
+ .player-choose {
81
+ all: unset; box-sizing: border-box; position: relative; width: 100%; aspect-ratio: 1; padding: 1rem 0.5rem 0;
82
+ display: flex; align-items: center; justify-content: center; overflow: hidden;
83
+ background: #fff; cursor: pointer; transition: padding 0.05s linear;
84
+ }
85
+ .player-choose::before, .player-choose::after { content: ''; position: absolute; inset: 0 1rem 0 3rem; background: rgb(180 180 180 / 0.2); }
86
+ .player-choose[data-player='sonic']::after { background: rgb(13 122 233 / 0.2); left: 30%; right: 20%; }
87
+ .player-choose[data-player='tails']::after { background: rgb(241 176 0 / 0.2); left: 40%; right: 10%; animation-delay: -5s; }
88
+ .player-choose[data-player='knuckles']::after { background: rgb(255 20 0 / 0.2); left: 20%; right: 30%; animation-delay: -10s; }
89
+ .player-choose[data-player='sonictails']::after { background: rgb(160 32 240 / 0.2); left: 20%; right: 70%; animation-delay: -15s; }
90
+ .slot-card:hover .player-choose, .slot-card.is-focused .player-choose { padding: 0; }
91
+ .player-choose:hover::before, .slot-card.is-focused .player-choose::before { animation: rsdk-player-bg 20s linear infinite; }
92
+ .player-choose:hover::after, .slot-card.is-focused .player-choose::after { animation: rsdk-player-bg 30s linear reverse infinite; }
93
+ .player-choose img { position: relative; z-index: 1; max-width: 100%; max-height: 100%; }
94
+ @keyframes rsdk-player-bg {
95
+ 0% { left: 30%; right: 20%; } 10% { left: 50%; right: 50%; } 20% { left: 10%; right: 20%; }
96
+ 30% { left: 60%; right: 40%; } 40% { left: 70%; right: 10%; } 50% { left: 20%; right: 80%; }
97
+ 60% { left: 0; right: 60%; } 70% { left: 40%; right: 10%; } 80% { left: 10%; right: 70%; }
98
+ 90% { left: 80%; right: 0; } 100% { left: 30%; right: 20%; }
99
+ }
100
+
101
+ /* a saved slot */
102
+ .slot-card.is-saved { gap: 0.25rem; }
103
+ .stage-shot { position: relative; width: 100%; flex-shrink: 0; }
104
+ .stage-badge {
105
+ width: 100%; aspect-ratio: 16 / 9; border-radius: 4px; overflow: hidden;
106
+ border: 1px solid rgb(255 255 255 / 0.2); background: linear-gradient(160deg, #1b2a4a, #080d1c);
107
+ }
108
+ .stage-badge img { width: 100%; height: 100%; object-fit: cover; filter: brightness(0.9); image-rendering: pixelated; }
109
+ .zone-tag {
110
+ position: absolute; top: 4px; left: 4px; padding: 1px 4px; border-radius: 3px; background: rgb(0 0 0 / 0.72);
111
+ color: #fbbf24; font-size: 0.5rem; font-weight: 800; letter-spacing: 0.06em; line-height: 1.4;
112
+ }
113
+ .player-icon { position: absolute; right: 0; bottom: -1rem; pointer-events: none; }
114
+ .player-icon img { height: calc(var(--w) * 64 / 170); width: auto; filter: drop-shadow(0 2px 4px rgb(0 0 0 / 0.85)); }
115
+ .zone-title {
116
+ height: 2.3em; padding-right: 26%; font-weight: 800; font-size: 0.72rem; color: #fbbf24; text-transform: uppercase;
117
+ letter-spacing: 0.4px; text-shadow: 0 1px 3px rgb(0 0 0 / 0.9); line-height: 1.15;
118
+ }
119
+ .emeralds { display: flex; justify-content: center; gap: 3px; flex-shrink: 0; }
120
+ .emeralds i { width: 11px; height: 11px; background: var(--gem); clip-path: polygon(50% 0, 100% 38%, 50% 100%, 0 38%); opacity: 0.2; }
121
+ .emeralds i.on { opacity: 1; filter: drop-shadow(0 0 3px rgb(255 255 255 / 0.4)); }
122
+ .stats-grid { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.2rem 0.4rem; font-size: 0.68rem; color: #e2e8f0; }
123
+ .stats-grid span {
124
+ display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.1rem 0.3rem; border-radius: 3px;
125
+ background: rgb(0 0 0 / 0.4); border: 1px solid rgb(255 255 255 / 0.1); font-variant-numeric: tabular-nums;
126
+ }
127
+ .stats-grid svg { width: 10px; height: 10px; }
128
+
129
+ /* ---- pause & confirm ---- */
130
+ .rsdk-panel {
131
+ width: min(440px, 92vw); display: grid; gap: 10px; padding: 26px; border-radius: 28px;
132
+ background: rgb(20 22 31 / 0.9); border: 1px solid var(--line); box-shadow: 0 40px 100px -30px #000;
133
+ }
134
+ .rsdk-panel h2 { margin-bottom: 8px; }
135
+ .rsdk-panel .text { margin: 0 0 8px; color: var(--muted); line-height: 1.5; }
136
+ .item {
137
+ all: unset; box-sizing: border-box; display: grid; gap: 2px; padding: 13px 16px; border-radius: 14px;
138
+ background: rgb(255 255 255 / 0.05); font-weight: 600; cursor: pointer; transition: background 0.15s, box-shadow 0.15s;
139
+ }
140
+ .item small { font-weight: 500; font-size: 0.78rem; color: var(--muted); }
141
+ .item.is-focused { background: #fff; color: #0b0d12; box-shadow: var(--ring); }
142
+ .item.is-focused small { color: #4b5563; }
143
+ .item.is-danger.is-focused { background: var(--danger); color: #1a0505; box-shadow: 0 0 0 3px var(--danger); }
144
+ .rsdk-panel .row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
145
+ .rsdk-panel .row .item { text-align: center; }
146
+ .rsdk-confirm { position: absolute; inset: 0; display: grid; place-items: center; background: rgb(0 0 0 / 0.55); }
147
+
148
+ /* ---- hints ---- */
149
+ .rsdk-hints { display: flex; flex-wrap: wrap; gap: 8px 18px; justify-content: center; color: var(--muted); font-size: 0.78rem; font-weight: 500; }
150
+ .rsdk-hints span { display: inline-flex; align-items: center; gap: 7px; }
151
+ .rsdk-hints kbd { display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 11px; background: rgb(255 255 255 / 0.12); color: var(--text); font: 700 0.68rem var(--ui); }
152
+ .rsdk-panel .rsdk-hints { justify-content: flex-start; margin-top: 6px; }
153
+
154
+ @media (max-width: 960px) {
155
+ .slot-cards { justify-content: flex-start; overflow-x: auto; padding: 16px 6px 18px; margin: 0 -6px; scrollbar-width: none; scroll-snap-type: x mandatory; }
156
+ .slot-card { scroll-snap-align: center; }
157
+ }
158
+ @media (prefers-reduced-motion: reduce) {
159
+ .slot-card, .player-choose, .player-choose::before, .player-choose::after, .rsdk-boot img { transition: none; animation: none; }
160
+ }