@wasm-gaming/rsdkv5-wasm 0.1.1 → 0.1.2

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/src/rsdkv5.sdk.ts CHANGED
@@ -1,50 +1,138 @@
1
- // @wasm-gaming/rsdkv5-wasm — SDK entry point.
2
-
3
- // Conforms to the wasm-gaming engine contract (github.com/wasm-gaming/engine-specs):
4
- // exports `manifest` (declarative) and `load(config)` (imperative).
5
-
6
- // Sonic Mania is built as RSDKv5U (compiled statically into the engine — see
7
- // docs/wasm-build-approach-b-sdk-contract.md). The SDK surface is game-agnostic:
8
- // `Data.rsdk` and `Settings.ini` are mounted at runtime into the FS, then main()
9
- // is started via callMain(). NOTE the capital S: the v5 engine reads
10
- // "Settings.ini" (case-sensitive under the Emscripten FS), unlike v4's
11
- // settings.ini.
1
+ /**
2
+ * The engine package's entry point: an `EngineSDK` for RSDKv5 (Sonic Mania).
3
+ *
4
+ * ```js
5
+ * import Rsdkv5SDK from '@wasm-gaming/rsdkv5-wasm';
6
+ *
7
+ * const play = await new Rsdkv5SDK()
8
+ * .assets({ data: () => readDataRsdk() }) // bytes, or a thunk
9
+ * .storage({ namespace: 'sonic-mania' }) // → OPFS rsdkv5/sonic-mania/
10
+ * .mount(document.querySelector('#stage'))
11
+ * .start();
12
+ *
13
+ * play.pause('menu'); play.resume('menu');
14
+ * play.devMenu.getStageList(); // the host draws its own stage select
15
+ * await play.destroy(); // save flushed, loop stopped, audio closed
16
+ * ```
17
+ *
18
+ * One game-agnostic `rsdkv5.wasm`: `Data.rsdk` is written into the filesystem at
19
+ * runtime, never baked in. The working dir `/data/rsdkv5/<namespace>` is in memory;
20
+ * Sonic Mania writes its user files there (`SaveData.bin`, `TimeAttackDB.bin`,
21
+ * `ReplayDB.bin`, `Replay_*.bin`). The SDK seeds them from OPFS `rsdkv5/<namespace>/`
22
+ * before boot and mirrors back whatever changed (every 10 s, on pause, on page hide
23
+ * and on destroy).
24
+ */
25
+ import {
26
+ EnginePlayBase,
27
+ EngineSDKBase,
28
+ EventEmitter,
29
+ type EngineInit,
30
+ type EngineSDKConstructor,
31
+ type EventsOf,
32
+ type PayloadKind,
33
+ } from '@wasm-gaming/engine-specs';
34
+ import {
35
+ RSDKV5_SPEC,
36
+ RSDKV5_VIDEO,
37
+ type Rsdkv5Asset,
38
+ type Rsdkv5Config,
39
+ type Rsdkv5Options,
40
+ type Rsdkv5Payloads,
41
+ } from './rsdkv5.options.js';
42
+
43
+ export type {
44
+ Rsdkv5Asset,
45
+ Rsdkv5Assets,
46
+ Rsdkv5Config,
47
+ Rsdkv5Events,
48
+ Rsdkv5Options,
49
+ Rsdkv5Payloads,
50
+ Rsdkv5Storage,
51
+ } from './rsdkv5.options.js';
52
+ export { RSDKV5_SPEC, RSDKV5_VIDEO } from './rsdkv5.options.js';
53
+
54
+ const ENGINE_ID = 'rsdkv5';
55
+ const DEFAULT_STORAGE_NAMESPACE = 'default';
56
+ const WORK_ROOT = '/data';
57
+ /** Inputs the SDK writes itself; everything else in the working dir is the game's. */
58
+ const NOT_SAVES = new Set(['Data.rsdk', 'Settings.ini', 'log.txt']);
59
+ const SAVE_INTERVAL_MS = 10_000;
60
+
61
+ /** The Emscripten module this build produces (`createRSDKv5` from rsdkv5.js). */
62
+ interface Rsdkv5Module {
63
+ FS: {
64
+ mkdir(path: string): void;
65
+ writeFile(path: string, data: Uint8Array): void;
66
+ readFile(path: string): Uint8Array;
67
+ readdir(path: string): string[];
68
+ stat(path: string): { mode: number };
69
+ isFile(mode: number): boolean;
70
+ chdir(path: string): void;
71
+ };
72
+ callMain(args: string[]): void;
73
+ pauseMainLoop?: () => void;
74
+ web_devmenu_get_stage_list?: () => string;
75
+ web_devmenu_load_stage?: (category: number, stage: number) => void;
76
+ web_devmenu_set_paused?: (paused: boolean) => void;
77
+ SDL2?: { audioContext?: AudioContext };
78
+ }
12
79
 
13
- import type { EngineConfig, EngineInstance, AssetData } from '@wasm-gaming/engine-specs';
14
- import { manifest } from './rsdkv5.manifest.js';
15
- import { DEFAULT_RSDKV5_OPTIONS, type Rsdkv5Options } from './rsdkv5.options.js';
80
+ type Rsdkv5Factory = (overrides: Record<string, unknown>) => Promise<Rsdkv5Module>;
16
81
 
17
- export { manifest };
82
+ /** The launcher's handle on RSDKv5's stage list, for drawing its own dev menu. */
83
+ export interface RsdkDevMenuBridge {
84
+ getStageList(): Array<{ name: string; stages: Array<{ name: string }> }>;
85
+ loadStage(category: number, stage: number): void;
86
+ }
18
87
 
19
- const WORK_ROOT = '/data';
20
- const DEFAULT_STORAGE_NAMESPACE = 'default';
88
+ /**
89
+ * Where an engine keeps one namespace: `<engineId>/<namespace>`, relative to the OPFS
90
+ * root. Identical in every wasm-gaming engine, written to move verbatim into
91
+ * engine-specs: segments cleaned, `.` and `..` dropped, anything outside
92
+ * `[A-Za-z0-9._-]` → `_`, nothing usable → `default`; a leading `<engineId>/` is
93
+ * stripped once.
94
+ */
95
+ export function storagePath(
96
+ engineId: string,
97
+ namespace: unknown,
98
+ ): { namespace: string; path: string; prefixed: boolean } {
99
+ const segments =
100
+ typeof namespace === 'string'
101
+ ? namespace
102
+ .split('/')
103
+ .map((segment) => segment.trim())
104
+ .filter((segment) => segment && segment !== '.' && segment !== '..')
105
+ .map((segment) => segment.replace(/[^A-Za-z0-9._-]/g, '_'))
106
+ : [];
107
+ const prefixed = segments.length > 1 && segments[0] === engineId;
108
+ if (prefixed) segments.shift();
109
+ const cleaned = segments.join('/') || DEFAULT_STORAGE_NAMESPACE;
110
+ return { namespace: cleaned, path: `${engineId}/${cleaned}`, prefixed };
111
+ }
21
112
 
22
113
  /**
23
- * Serialize engine options into RSDKv5U's Settings.ini format. This is the v5
24
- * dialect (lowercase keys, 0/1 booleans, `dataFile=` in [Game]) — matching the
25
- * exact file the proven build preloaded — NOT v4's [Dev]/CamelCase format.
26
- * `dataFile=Data.rsdk` is load-bearing: it names the pack the engine opens,
27
- * relative to CWD (the SDK chdir()s into the working dir before callMain).
114
+ * Serialise the engine config into RSDKv5U's `Settings.ini` (capital S — the engine
115
+ * opens exactly that name, and the Emscripten FS is case-sensitive). The v5 dialect:
116
+ * lowercase keys, 0/1 booleans, `dataFile=` naming the pack relative to the working dir.
28
117
  */
29
- function buildSettingsIni(options: Rsdkv5Options = {}): string {
30
- const o = { ...DEFAULT_RSDKV5_OPTIONS, ...options };
31
- const b = (v: boolean) => (v ? '1' : '0');
118
+ export function buildSettingsIni(config: Rsdkv5Config): string {
119
+ const b = (v: boolean | undefined) => (v ? '1' : '0');
32
120
  return [
33
121
  '[Game]',
34
122
  'dataFile=Data.rsdk',
35
- `devMenu=${b(o.devMenu)}`,
36
- `language=${o.language | 0}`,
37
- `region=${o.region | 0}`,
123
+ `devMenu=${b(config.devMenu)}`,
124
+ `language=${(config.language ?? 0) | 0}`,
125
+ `region=${(config.region ?? -1) | 0}`,
38
126
  '',
39
127
  '[Video]',
40
128
  'windowed=1',
41
129
  'border=1',
42
130
  'exclusiveFS=0',
43
- `vsync=${b(o.vsync)}`,
131
+ `vsync=${b(config.vsync ?? true)}`,
44
132
  'tripleBuffering=0',
45
- 'pixWidth=424',
46
- 'winWidth=424',
47
- 'winHeight=240',
133
+ `pixWidth=${RSDKV5_VIDEO.width}`,
134
+ `winWidth=${RSDKV5_VIDEO.width}`,
135
+ `winHeight=${RSDKV5_VIDEO.height}`,
48
136
  'fsWidth=0',
49
137
  'fsHeight=0',
50
138
  'refreshRate=60',
@@ -59,216 +147,344 @@ function buildSettingsIni(options: Rsdkv5Options = {}): string {
59
147
  ].join('\n');
60
148
  }
61
149
 
62
- function toUint8(x: unknown): Uint8Array | null {
63
- if (x == null) return null;
64
- if (typeof x === 'string') return new TextEncoder().encode(x);
65
- if (x instanceof Uint8Array) return x;
66
- if (x instanceof ArrayBuffer) return new Uint8Array(x);
67
- if (ArrayBuffer.isView(x)) return new Uint8Array(x.buffer, x.byteOffset, x.byteLength);
68
- throw new TypeError('asset must be Uint8Array | ArrayBuffer | string');
150
+ async function opfsDir(path: string, create: boolean): Promise<FileSystemDirectoryHandle | null> {
151
+ if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return null;
152
+ try {
153
+ let dir = await navigator.storage.getDirectory();
154
+ for (const segment of path.split('/').filter(Boolean)) {
155
+ dir = await dir.getDirectoryHandle(segment, { create });
156
+ }
157
+ return dir;
158
+ } catch {
159
+ return null;
160
+ }
69
161
  }
70
162
 
71
- /** Normalize a user-provided storage namespace into a safe relative path. */
72
- function normalizeStorageNamespace(namespace: unknown): string {
73
- if (typeof namespace !== 'string' || !namespace.trim()) return DEFAULT_STORAGE_NAMESPACE;
74
-
75
- const cleaned = namespace
76
- .split('/')
77
- .map((segment) => segment.trim())
78
- .filter(Boolean)
79
- .map((segment) => segment.replace(/[^A-Za-z0-9._-]/g, '_'))
80
- .filter(Boolean)
81
- .join('/');
163
+ async function resolveAsset(asset: Rsdkv5Asset | undefined): Promise<Uint8Array | null> {
164
+ const value = typeof asset === 'function' ? await asset() : asset;
165
+ if (value == null) return null;
166
+ if (value instanceof Uint8Array) return value;
167
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
168
+ throw new TypeError('rsdkv5: an asset must be a Uint8Array, an ArrayBuffer or a thunk returning one');
169
+ }
82
170
 
83
- return cleaned || DEFAULT_STORAGE_NAMESPACE;
171
+ function sameBytes(a: Uint8Array | null, b: Uint8Array): boolean {
172
+ if (!a || a.length !== b.length) return false;
173
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
174
+ return true;
84
175
  }
85
176
 
86
- /** Best-effort mkdir -p for the Emscripten FS layer. */
87
- function ensureDir(Module: any, path: string): void {
88
- const parts = path.split('/').filter(Boolean);
177
+ function mkdirp(Module: Rsdkv5Module, path: string): void {
89
178
  let current = '';
90
- for (const part of parts) {
179
+ for (const part of path.split('/').filter(Boolean)) {
91
180
  current += `/${part}`;
92
181
  try {
93
182
  Module.FS.mkdir(current);
94
183
  } catch {
95
- /* already exists */
184
+ /* exists */
96
185
  }
97
186
  }
98
187
  }
99
188
 
100
- /** Mount the game working directory. */
101
- function mountWorkingDir(Module: any, storageNamespace: string): { persistent: boolean; workDir: string } {
102
- const workDir = `${WORK_ROOT}/${storageNamespace}`;
103
- ensureDir(Module, workDir);
104
- return { persistent: false, workDir };
105
- }
189
+ /** The live engine: one booted RSDKv5 and its session. */
190
+ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
191
+ #module: Rsdkv5Module | null = null;
192
+ #destroyed = false;
193
+ #pauseOwner: string | null = null;
194
+
195
+ #canvas: HTMLCanvasElement | null = null;
196
+ #ownsCanvas = false;
197
+ #box: HTMLElement | null = null;
198
+ #boxObserver: ResizeObserver | null = null;
199
+
200
+ #namespace = DEFAULT_STORAGE_NAMESPACE;
201
+ #storagePath = `${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`;
202
+ #workDir = `${WORK_ROOT}/${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`;
203
+ #persist = true;
204
+ #saveTimer: ReturnType<typeof setInterval> | null = null;
205
+ #lastSaved = new Map<string, Uint8Array>();
206
+ #flushing: Promise<void> | null = null;
207
+ #onHidden = (): void => {
208
+ if (document.visibilityState === 'hidden') void this.flushSaves();
209
+ };
106
210
 
107
- /** True if `path` exists in the (mounted) filesystem. */
108
- function fileExists(Module: any, path: string): boolean {
109
- try {
110
- Module.FS.stat(path);
111
- return true;
112
- } catch {
113
- return false;
211
+ constructor(init?: EngineInit<Rsdkv5Payloads>, events?: EventEmitter<EventsOf<Rsdkv5Payloads>>) {
212
+ super(RSDKV5_SPEC, init, events);
114
213
  }
115
- }
116
214
 
117
- /** Delete a file if present; returns true when something was removed. */
118
- function deleteFileIfExists(Module: any, path: string): boolean {
119
- if (!fileExists(Module, path)) return false;
120
- try {
121
- Module.FS.unlink(path);
122
- return true;
123
- } catch {
124
- return false;
215
+ get namespace(): string {
216
+ return this.#namespace;
125
217
  }
126
- }
127
218
 
128
- /** RSDKv5-specific bridge for the launcher's debug/stage-select UI. */
129
- export interface RsdkDevMenuBridge {
130
- getStageList(): Array<{ name: string; stages: Array<{ name: string }> }>;
131
- loadStage(categoryIdx: number, stageIdx: number): void;
132
- setPaused(paused: boolean): void;
133
- }
219
+ /** Where this session's files live, relative to the OPFS root: `rsdkv5/<namespace>`. */
220
+ get storagePath(): string {
221
+ return this.#storagePath;
222
+ }
134
223
 
135
- export type Rsdkv5Instance = EngineInstance & {
136
- devMenu: RsdkDevMenuBridge;
137
- /** True when the working dir is OPFS-backed (persistent) rather than in-memory. */
138
- persistent: boolean;
139
- /** Relative storage namespace used under /data (e.g. "sonic-mania"). */
140
- storageNamespace: string;
141
- /** Remove persisted game files for this namespace only. */
142
- purgeStorage(): { data: boolean; settings: boolean };
143
- };
224
+ get pausedBy(): string | null {
225
+ return this.#pauseOwner;
226
+ }
144
227
 
145
- /**
146
- * Extra (engine-specific) config on top of the contract's EngineConfig: lazy asset
147
- * providers, invoked only on a cache miss.
148
- */
149
- export type Rsdkv5LoadConfig = EngineConfig & {
150
- dataProvider?: () => Promise<AssetData> | AssetData;
151
- settingsProvider?: () => Promise<AssetData> | AssetData;
152
- /** Per-game storage folder under /data used for OPFS/WASMFS files. */
153
- storageNamespace?: string;
154
- };
155
-
156
- /** Boot the RSDKv5 engine. */
157
- export async function load(config: Rsdkv5LoadConfig): Promise<Rsdkv5Instance> {
158
- const { canvas, assets, onEvent } = config;
159
- const options = config.options as Rsdkv5Options | undefined;
160
- if (!canvas) throw new Error('rsdkv5: config.canvas is required');
161
-
162
- // Emscripten's SDL2 port locates the canvas via document.querySelector('#canvas').
163
- if (canvas.id !== 'canvas') canvas.id = 'canvas';
164
-
165
- const emit = (e: Parameters<NonNullable<EngineConfig['onEvent']>>[0]) => {
166
- try { onEvent?.(e); } catch { /* host handler must not break us */ }
167
- };
228
+ /** RSDKv5's stage list, for a host that draws its own stage select. */
229
+ get devMenu(): RsdkDevMenuBridge {
230
+ const Module = (): Rsdkv5Module | null => this.#module;
231
+ return {
232
+ getStageList() {
233
+ try {
234
+ return JSON.parse(Module()?.web_devmenu_get_stage_list?.() ?? '[]');
235
+ } catch {
236
+ return [];
237
+ }
238
+ },
239
+ loadStage(category: number, stage: number) {
240
+ Module()?.web_devmenu_load_stage?.(category | 0, stage | 0);
241
+ },
242
+ };
243
+ }
168
244
 
169
- const jsUrl = config.jsUrl ?? new URL('./rsdkv5.js', import.meta.url).href;
170
- const wasmUrl = config.wasmUrl ?? new URL('./rsdkv5.wasm', import.meta.url).href;
171
-
172
- // RSDKv5 uses the same key map preset used by the shared launcher script.
173
- if (typeof window !== 'undefined') (window as any).__gamepadKeyMap = manifest.input;
174
-
175
- const mod: any = await import(/* @vite-ignore */ jsUrl);
176
- const createRSDKv5 = mod.default;
177
-
178
- const Module: any = await createRSDKv5({
179
- canvas,
180
- noInitialRun: true,
181
- locateFile: (path: string) => (path.endsWith('.wasm') ? wasmUrl : path),
182
- print: (...a: unknown[]) => console.log('[rsdkv5]', ...a),
183
- printErr: (...a: unknown[]) => console.error('[rsdkv5]', ...a),
184
- onAbort: (reason: unknown) =>
185
- emit({ type: 'error', error: new Error(`rsdkv5 aborted: ${reason}`) }),
186
- });
187
-
188
- const storageNamespace = normalizeStorageNamespace(config.storageNamespace);
189
- const { persistent, workDir } = mountWorkingDir(Module, storageNamespace);
190
- const dataPath = `${workDir}/Data.rsdk`;
191
- // Capital S: RSDKv5U opens "Settings.ini" and the Emscripten FS is
192
- // case-sensitive (v4 used lowercase settings.ini — do not copy that here).
193
- const settingsPath = `${workDir}/Settings.ini`;
194
-
195
- // Data.rsdk — precedence: explicit asset > lazy provider > existing file.
196
- let dataBytes = toUint8(assets?.data);
197
- if (!dataBytes && config.dataProvider) {
198
- dataBytes = toUint8(await config.dataProvider());
245
+ /**
246
+ * Freeze the game and its sound. The first caller owns the pause; only that
247
+ * owner's {@link resume} lifts it. The save is flushed.
248
+ */
249
+ pause(owner = 'host'): void {
250
+ const Module = this.#module;
251
+ if (!Module || this.#pauseOwner) return;
252
+ this.#pauseOwner = owner;
253
+ Module.web_devmenu_set_paused?.(true);
254
+ void Module.SDL2?.audioContext?.suspend();
255
+ void this.flushSaves();
256
+ this.emit('pause', { owner });
199
257
  }
200
- if (dataBytes) {
201
- Module.FS.writeFile(dataPath, dataBytes);
202
- } else if (!fileExists(Module, dataPath)) {
203
- throw new Error('rsdkv5: no Data.rsdk — provide assets.data or dataProvider');
258
+
259
+ resume(owner = 'host'): void {
260
+ const Module = this.#module;
261
+ if (!Module || this.#pauseOwner !== owner) return;
262
+ this.#pauseOwner = null;
263
+ void Module.SDL2?.audioContext?.resume();
264
+ Module.web_devmenu_set_paused?.(false);
265
+ this.emit('resume', { owner });
204
266
  }
205
267
 
206
- // settings.ini — explicit asset > lazy provider > generated from options.
207
- let settingsBytes = toUint8(assets?.settings);
208
- if (!settingsBytes && config.settingsProvider) {
209
- settingsBytes = toUint8(await config.settingsProvider());
268
+ /** Copy the game's changed user files to OPFS `rsdkv5/<namespace>/`; emits `saves`. */
269
+ flushSaves(): Promise<void> {
270
+ this.#flushing ??= this.#flush().finally(() => {
271
+ this.#flushing = null;
272
+ });
273
+ return this.#flushing;
210
274
  }
211
- if (settingsBytes) {
212
- Module.FS.writeFile(settingsPath, settingsBytes);
213
- } else if (!fileExists(Module, settingsPath)) {
214
- Module.FS.writeFile(settingsPath, new TextEncoder().encode(buildSettingsIni(options)));
275
+
276
+ // ---- Contract hooks ----
277
+
278
+ protected override mounted(target: HTMLElement): void {
279
+ if (target instanceof HTMLCanvasElement) {
280
+ this.#canvas = target;
281
+ this.#ownsCanvas = false;
282
+ this.#box = target.parentElement;
283
+ } else {
284
+ this.#canvas = document.createElement('canvas');
285
+ this.#ownsCanvas = true;
286
+ this.#box = target;
287
+ target.appendChild(this.#canvas);
288
+ }
289
+ // Emscripten's SDL2 port finds its window with querySelector('#canvas').
290
+ this.#canvas.id = 'canvas';
291
+ this.#canvas.tabIndex = 0;
292
+ this.#canvas.style.display = 'block';
293
+ this.#canvas.style.imageRendering = 'pixelated';
294
+ this.#canvas.addEventListener('contextmenu', (event) => event.preventDefault());
295
+ if (this.#ownsCanvas && this.#box) {
296
+ this.#boxObserver?.disconnect();
297
+ this.#boxObserver = new ResizeObserver(() => this.#fit());
298
+ this.#boxObserver.observe(this.#box);
299
+ }
215
300
  }
216
301
 
217
- Module.FS.chdir(workDir);
302
+ protected override async open(_options: Rsdkv5Options): Promise<void> {
303
+ if (this.#destroyed || this.#module) {
304
+ // RSDKv5 cannot restart in process: main() runs once and owns the loop.
305
+ throw new Error('rsdkv5: the engine cannot be restarted in process — destroy() and start a new page (or iframe)');
306
+ }
307
+ await this.#boot();
308
+ }
218
309
 
219
- const setPaused = (paused: boolean) => {
220
- if (typeof Module.web_devmenu_set_paused === 'function') Module.web_devmenu_set_paused(paused);
221
- };
310
+ protected override patched(_kind: PayloadKind, _patch: object): void {
311
+ // Nothing in RSDKV5_SPEC is live: Settings.ini is read once, at boot.
312
+ }
222
313
 
223
- const devMenu: RsdkDevMenuBridge = {
224
- getStageList() {
225
- if (typeof Module.web_devmenu_get_stage_list !== 'function') return [];
314
+ protected override async close(): Promise<void> {
315
+ if (this.#saveTimer) clearInterval(this.#saveTimer);
316
+ this.#saveTimer = null;
317
+ document.removeEventListener('visibilitychange', this.#onHidden);
318
+ window.removeEventListener('pagehide', this.#onHidden);
319
+ const Module = this.#module;
320
+ if (Module) {
321
+ await this.flushSaves();
226
322
  try {
227
- return JSON.parse(Module.web_devmenu_get_stage_list());
228
- } catch (e) {
229
- console.error('[rsdkv5] getStageList failed', e);
230
- return [];
323
+ Module.web_devmenu_set_paused?.(true);
324
+ Module.pauseMainLoop?.();
325
+ } catch {
326
+ /* already stopped */
231
327
  }
232
- },
233
- loadStage(categoryIdx, stageIdx) {
234
- if (typeof Module.web_devmenu_load_stage === 'function') {
235
- Module.web_devmenu_load_stage(categoryIdx | 0, stageIdx | 0);
328
+ try {
329
+ await Module.SDL2?.audioContext?.close();
330
+ } catch {
331
+ /* already closed */
236
332
  }
237
- },
238
- setPaused,
239
- };
333
+ }
334
+ this.#boxObserver?.disconnect();
335
+ this.#boxObserver = null;
336
+ if (this.#ownsCanvas) this.#canvas?.remove();
337
+ this.#canvas = null;
338
+ this.#module = null;
339
+ this.#pauseOwner = null;
340
+ this.#destroyed = true;
341
+ }
342
+
343
+ // ---- Boot ----
344
+
345
+ async #boot(): Promise<void> {
346
+ const config = await this.#values<Rsdkv5Config>('config');
347
+ const assets = await this.#values<{ data?: Rsdkv5Asset; settings?: Rsdkv5Asset }>('assets');
348
+ const storage = await this.#values<{ namespace?: string; persist?: 'opfs' | null }>('storage');
349
+ if (!this.#canvas) throw new Error('rsdkv5: mount() an element (or a canvas) before start()');
350
+
351
+ const data = await resolveAsset(assets.data);
352
+ if (!data) throw new Error('rsdkv5: no Data.rsdk — hand one over as assets.data (bytes or a thunk)');
353
+ const settings = await resolveAsset(assets.settings);
240
354
 
241
- // No 'UsingCWD' argv — that's a v4-ism; v5U's main() doesn't parse it. The
242
- // engine resolves Settings.ini/dataFile relative to the FS CWD set above.
243
- Module.callMain([]);
244
- emit({ type: 'ready' });
245
-
246
- return {
247
- start() {},
248
- pause() { setPaused(true); },
249
- resume() { setPaused(false); },
250
- reset() {
251
- throw new Error('rsdkv5: reset() is not supported — destroy() and load() again');
252
- },
253
- setInput(preset) {
254
- if (typeof window !== 'undefined') {
255
- (window as any).__gamepadKeyMap = preset ?? manifest.input;
355
+ const resolved = storagePath(ENGINE_ID, storage.namespace);
356
+ if (resolved.prefixed) {
357
+ console.warn(`[rsdkv5] storage.namespace no longer needs the "${ENGINE_ID}/" prefix. Pass "${resolved.namespace}".`);
358
+ }
359
+ this.#namespace = resolved.namespace;
360
+ this.#storagePath = resolved.path;
361
+ this.#workDir = `${WORK_ROOT}/${resolved.path}`;
362
+ this.#persist = storage.persist !== null;
363
+
364
+ const jsUrl = config.jsUrl ?? new URL('./rsdkv5.js', import.meta.url).href;
365
+ const wasmUrl = config.wasmUrl ?? new URL('./rsdkv5.wasm', import.meta.url).href;
366
+ const { default: createRSDKv5 } = (await import(/* @vite-ignore */ jsUrl)) as { default: Rsdkv5Factory };
367
+
368
+ let failed: ((error: Error) => void) | null = null;
369
+ const Module = await createRSDKv5({
370
+ canvas: this.#canvas,
371
+ noInitialRun: true,
372
+ locateFile: (path: string) => (path.endsWith('.wasm') ? wasmUrl : new URL(path, jsUrl).href),
373
+ print: (text: string) => console.log('[rsdkv5]', text),
374
+ printErr: (text: string) => console.warn('[rsdkv5]', text),
375
+ onAbort: (what: unknown) => failed?.(new Error(`rsdkv5: aborted — ${String(what)}`)),
376
+ });
377
+ this.#module = Module;
378
+ failed = (error) => this.emit('error', error);
379
+
380
+ mkdirp(Module, this.#workDir);
381
+ Module.FS.writeFile(`${this.#workDir}/Data.rsdk`, data);
382
+ Module.FS.writeFile(
383
+ `${this.#workDir}/Settings.ini`,
384
+ settings ?? new TextEncoder().encode(buildSettingsIni(config)),
385
+ );
386
+ if (this.#persist) await this.#restoreSave();
387
+
388
+ Module.FS.chdir(this.#workDir);
389
+ // RSDKv5U resolves Settings.ini and dataFile relative to the CWD set above.
390
+ Module.callMain([]);
391
+
392
+ if (this.#persist) {
393
+ this.#saveTimer = setInterval(() => void this.flushSaves(), SAVE_INTERVAL_MS);
394
+ document.addEventListener('visibilitychange', this.#onHidden);
395
+ window.addEventListener('pagehide', this.#onHidden);
396
+ }
397
+ this.#fit();
398
+ }
399
+
400
+ async #values<T>(kind: 'config' | 'assets' | 'storage'): Promise<T> {
401
+ const described = (await this[kind]()) as Record<string, { value: unknown }>;
402
+ const out: Record<string, unknown> = {};
403
+ for (const [key, property] of Object.entries(described)) out[key] = property.value;
404
+ return out as T;
405
+ }
406
+
407
+ // ---- Save mirror ----
408
+
409
+ async #restoreSave(): Promise<void> {
410
+ const dir = await opfsDir(this.#storagePath, false);
411
+ if (!dir || !this.#module) return;
412
+ const iterable = dir as unknown as { entries(): AsyncIterable<[string, FileSystemHandle]> };
413
+ for await (const [name, handle] of iterable.entries()) {
414
+ if (handle.kind !== 'file' || NOT_SAVES.has(name)) continue;
415
+ const bytes = new Uint8Array(await (await (handle as FileSystemFileHandle).getFile()).arrayBuffer());
416
+ this.#module.FS.writeFile(`${this.#workDir}/${name}`, bytes);
417
+ this.#lastSaved.set(name, bytes);
418
+ }
419
+ }
420
+
421
+ async #flush(): Promise<void> {
422
+ const Module = this.#module;
423
+ if (!Module || !this.#persist) return;
424
+ const changed: Array<[string, Uint8Array<ArrayBuffer>]> = [];
425
+ let names: string[];
426
+ try {
427
+ names = Module.FS.readdir(this.#workDir);
428
+ } catch {
429
+ return;
430
+ }
431
+ for (const name of names) {
432
+ if (name === '.' || name === '..' || NOT_SAVES.has(name)) continue;
433
+ const path = `${this.#workDir}/${name}`;
434
+ if (!Module.FS.isFile(Module.FS.stat(path).mode)) continue;
435
+ const bytes = new Uint8Array(Module.FS.readFile(path));
436
+ if (!sameBytes(this.#lastSaved.get(name) ?? null, bytes)) changed.push([name, bytes]);
437
+ }
438
+ if (changed.length === 0) return;
439
+ const dir = await opfsDir(this.#storagePath, true);
440
+ if (!dir) return;
441
+ const written: string[] = [];
442
+ for (const [name, bytes] of changed) {
443
+ try {
444
+ const writable = await (await dir.getFileHandle(name, { create: true })).createWritable();
445
+ await writable.write(bytes);
446
+ await writable.close();
447
+ this.#lastSaved.set(name, bytes);
448
+ written.push(name);
449
+ } catch (e) {
450
+ console.warn(`[rsdkv5] could not persist ${name}`, e);
256
451
  }
257
- },
258
- destroy() {
259
- try { Module.pauseMainLoop?.(); } catch { /* noop */ }
260
- try { setPaused(true); } catch { /* noop */ }
261
- },
262
- devMenu,
263
- persistent,
264
- storageNamespace,
265
- purgeStorage() {
266
- return {
267
- data: deleteFileIfExists(Module, dataPath),
268
- settings: deleteFileIfExists(Module, settingsPath),
269
- };
270
- },
271
- };
452
+ }
453
+ if (written.length) this.emit('saves', { files: written });
454
+ }
455
+
456
+ // ---- Picture ----
457
+
458
+ #fit(): void {
459
+ const canvas = this.#canvas;
460
+ const box = this.#box;
461
+ if (!canvas || !box || !this.#ownsCanvas) return;
462
+ const { width, height } = box.getBoundingClientRect();
463
+ if (!width || !height) return;
464
+ const scale = Math.min(width / RSDKV5_VIDEO.width, height / RSDKV5_VIDEO.height);
465
+ canvas.style.width = `${Math.floor(RSDKV5_VIDEO.width * scale)}px`;
466
+ canvas.style.height = `${Math.floor(RSDKV5_VIDEO.height * scale)}px`;
467
+ }
272
468
  }
273
469
 
274
- export default { manifest, load };
470
+ /** The factory a host constructs: static identity, the configuration chain, `start()`. */
471
+ export class Rsdkv5SDK extends EngineSDKBase<Rsdkv5Payloads> {
472
+ static readonly id = ENGINE_ID;
473
+ /** Kept in step with package.json by `node scripts/sync-version.mjs`. */
474
+ static readonly version = '0.1.2';
475
+ static readonly name = 'Retro Software Development Kit v3';
476
+ static readonly description =
477
+ 'RSDKv5U (Sonic Mania decompilation) compiled to WebAssembly. Ships none of the game: the player provides Data.rsdk, and it never leaves their browser.';
478
+
479
+ protected override createPlay(
480
+ init: EngineInit<Rsdkv5Payloads>,
481
+ events: EventEmitter<EventsOf<Rsdkv5Payloads>>,
482
+ ): Rsdkv5Play {
483
+ return new Rsdkv5Play(init, events);
484
+ }
485
+ }
486
+
487
+ /** CI's conformance line: if this class stops satisfying the contract, `make test` fails. */
488
+ export const RSDKV5_ENGINE: EngineSDKConstructor<Rsdkv5Payloads> = Rsdkv5SDK;
489
+
490
+ export default Rsdkv5SDK;