@wasm-gaming/rsdkv5-wasm 0.1.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.
@@ -0,0 +1,274 @@
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.
12
+
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';
16
+
17
+ export { manifest };
18
+
19
+ const WORK_ROOT = '/data';
20
+ const DEFAULT_STORAGE_NAMESPACE = 'default';
21
+
22
+ /**
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).
28
+ */
29
+ function buildSettingsIni(options: Rsdkv5Options = {}): string {
30
+ const o = { ...DEFAULT_RSDKV5_OPTIONS, ...options };
31
+ const b = (v: boolean) => (v ? '1' : '0');
32
+ return [
33
+ '[Game]',
34
+ 'dataFile=Data.rsdk',
35
+ `devMenu=${b(o.devMenu)}`,
36
+ `language=${o.language | 0}`,
37
+ `region=${o.region | 0}`,
38
+ '',
39
+ '[Video]',
40
+ 'windowed=1',
41
+ 'border=1',
42
+ 'exclusiveFS=0',
43
+ `vsync=${b(o.vsync)}`,
44
+ 'tripleBuffering=0',
45
+ 'pixWidth=424',
46
+ 'winWidth=424',
47
+ 'winHeight=240',
48
+ 'fsWidth=0',
49
+ 'fsHeight=0',
50
+ 'refreshRate=60',
51
+ 'shaderSupport=0',
52
+ 'screenShader=0',
53
+ '',
54
+ '[Audio]',
55
+ 'streamsEnabled=1',
56
+ 'streamVolume=0.8',
57
+ 'sfxVolume=1.0',
58
+ '',
59
+ ].join('\n');
60
+ }
61
+
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');
69
+ }
70
+
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('/');
82
+
83
+ return cleaned || DEFAULT_STORAGE_NAMESPACE;
84
+ }
85
+
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);
89
+ let current = '';
90
+ for (const part of parts) {
91
+ current += `/${part}`;
92
+ try {
93
+ Module.FS.mkdir(current);
94
+ } catch {
95
+ /* already exists */
96
+ }
97
+ }
98
+ }
99
+
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
+ }
106
+
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;
114
+ }
115
+ }
116
+
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;
125
+ }
126
+ }
127
+
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
+ }
134
+
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
+ };
144
+
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
+ };
168
+
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());
199
+ }
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');
204
+ }
205
+
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());
210
+ }
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)));
215
+ }
216
+
217
+ Module.FS.chdir(workDir);
218
+
219
+ const setPaused = (paused: boolean) => {
220
+ if (typeof Module.web_devmenu_set_paused === 'function') Module.web_devmenu_set_paused(paused);
221
+ };
222
+
223
+ const devMenu: RsdkDevMenuBridge = {
224
+ getStageList() {
225
+ if (typeof Module.web_devmenu_get_stage_list !== 'function') return [];
226
+ try {
227
+ return JSON.parse(Module.web_devmenu_get_stage_list());
228
+ } catch (e) {
229
+ console.error('[rsdkv5] getStageList failed', e);
230
+ return [];
231
+ }
232
+ },
233
+ loadStage(categoryIdx, stageIdx) {
234
+ if (typeof Module.web_devmenu_load_stage === 'function') {
235
+ Module.web_devmenu_load_stage(categoryIdx | 0, stageIdx | 0);
236
+ }
237
+ },
238
+ setPaused,
239
+ };
240
+
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;
256
+ }
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
+ };
272
+ }
273
+
274
+ export default { manifest, load };