@wasm-gaming/rsdkv4-wasm 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/README.md +229 -0
- package/dist/manifest.json +75 -0
- package/dist/rsdkv4/rsdkv4.js +9268 -0
- package/dist/rsdkv4/rsdkv4.manifest.d.ts +4 -0
- package/dist/rsdkv4/rsdkv4.manifest.d.ts.map +1 -0
- package/dist/rsdkv4/rsdkv4.manifest.js +39 -0
- package/dist/rsdkv4/rsdkv4.manifest.js.map +1 -0
- package/dist/rsdkv4/rsdkv4.options.d.ts +22 -0
- package/dist/rsdkv4/rsdkv4.options.d.ts.map +1 -0
- package/dist/rsdkv4/rsdkv4.options.js +41 -0
- package/dist/rsdkv4/rsdkv4.options.js.map +1 -0
- package/dist/rsdkv4/rsdkv4.sdk.d.ts +48 -0
- package/dist/rsdkv4/rsdkv4.sdk.d.ts.map +1 -0
- package/dist/rsdkv4/rsdkv4.sdk.js +257 -0
- package/dist/rsdkv4/rsdkv4.sdk.js.map +1 -0
- package/dist/rsdkv4/rsdkv4.wasm +0 -0
- package/package.json +51 -0
- package/src/rsdkv4.manifest.ts +44 -0
- package/src/rsdkv4.options.ts +64 -0
- package/src/rsdkv4.sdk.ts +294 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// @wasm-gaming/rsdkv4-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
|
+
// One game-agnostic rsdkv4.wasm runs both Sonic 1 and Sonic 2 — the difference is
|
|
7
|
+
// only which Data.rsdk the host hands us in `config.assets.data`, written into the
|
|
8
|
+
// filesystem at runtime (not baked with --preload-file).
|
|
9
|
+
//
|
|
10
|
+
// Filesystem: built with -sWASMFS (the modern Emscripten filesystem, not MEMFS).
|
|
11
|
+
// The game working dir `/data` is mounted on **OPFS** for persistence when the
|
|
12
|
+
// page is cross-origin isolated; otherwise it falls back to the WASMFS in-memory
|
|
13
|
+
// backend (transient, but the engine still boots). The backend is selectable via
|
|
14
|
+
// `config.persist` (see mountWorkingDir), and when the dir is OPFS-backed the SDK
|
|
15
|
+
// reuses an already-persisted Data.rsdk/settings.ini instead of re-fetching.
|
|
16
|
+
|
|
17
|
+
import type { EngineConfig, EngineInstance, AssetData } from '@wasm-gaming/engine-specs';
|
|
18
|
+
import { manifest } from './rsdkv4.manifest.js';
|
|
19
|
+
import { DEFAULT_RSDKV4_OPTIONS, type Rsdkv4Options } from './rsdkv4.options.js';
|
|
20
|
+
|
|
21
|
+
export { manifest };
|
|
22
|
+
|
|
23
|
+
const WORK_ROOT = '/data';
|
|
24
|
+
const DEFAULT_STORAGE_NAMESPACE = 'default';
|
|
25
|
+
|
|
26
|
+
/** Serialize engine options into RSDKv4's settings.ini format. */
|
|
27
|
+
function buildSettingsIni(options: Rsdkv4Options = {}): string {
|
|
28
|
+
const o = { ...DEFAULT_RSDKV4_OPTIONS, ...options };
|
|
29
|
+
return [
|
|
30
|
+
'[Dev]',
|
|
31
|
+
`EngineDebugMode=${o.engineDebugMode ? 'true' : 'false'}`,
|
|
32
|
+
`DevMenu=${o.devMenu ? 'true' : 'false'}`,
|
|
33
|
+
`StartingCategory=${o.startingCategory | 0}`,
|
|
34
|
+
`StartingScene=${o.startingScene | 0}`,
|
|
35
|
+
`StartingPlayer=${o.startingPlayer | 0}`,
|
|
36
|
+
'',
|
|
37
|
+
'[Window]',
|
|
38
|
+
`VSync=${o.vsync ? 'true' : 'false'}`,
|
|
39
|
+
'',
|
|
40
|
+
].join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toUint8(x: unknown): Uint8Array | null {
|
|
44
|
+
if (x == null) return null;
|
|
45
|
+
if (typeof x === 'string') return new TextEncoder().encode(x);
|
|
46
|
+
if (x instanceof Uint8Array) return x;
|
|
47
|
+
if (x instanceof ArrayBuffer) return new Uint8Array(x);
|
|
48
|
+
if (ArrayBuffer.isView(x)) return new Uint8Array(x.buffer, x.byteOffset, x.byteLength);
|
|
49
|
+
throw new TypeError('asset must be Uint8Array | ArrayBuffer | string');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Normalize a user-provided storage namespace into a safe relative path. */
|
|
53
|
+
function normalizeStorageNamespace(namespace: unknown): string {
|
|
54
|
+
if (typeof namespace !== 'string' || !namespace.trim()) return DEFAULT_STORAGE_NAMESPACE;
|
|
55
|
+
|
|
56
|
+
const cleaned = namespace
|
|
57
|
+
.split('/')
|
|
58
|
+
.map((segment) => segment.trim())
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.map((segment) => segment.replace(/[^A-Za-z0-9._-]/g, '_'))
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.join('/');
|
|
63
|
+
|
|
64
|
+
return cleaned || DEFAULT_STORAGE_NAMESPACE;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Best-effort mkdir -p for the Emscripten FS layer. */
|
|
68
|
+
function ensureDir(Module: any, path: string): void {
|
|
69
|
+
const parts = path.split('/').filter(Boolean);
|
|
70
|
+
let current = '';
|
|
71
|
+
for (const part of parts) {
|
|
72
|
+
current += `/${part}`;
|
|
73
|
+
try {
|
|
74
|
+
Module.FS.mkdir(current);
|
|
75
|
+
} catch {
|
|
76
|
+
/* already exists */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Mount the game working dir, honoring `persist`:
|
|
83
|
+
* 'opfs' → force an OPFS (persistent) mount; warn + fall back if unavailable.
|
|
84
|
+
* null → force the in-memory WASMFS backend (the MEMFS-equivalent fallback).
|
|
85
|
+
* undefined → auto: OPFS when the page is cross-origin isolated, else in-memory.
|
|
86
|
+
* ('idbfs' isn't supported under WASMFS and is treated as in-memory.)
|
|
87
|
+
*
|
|
88
|
+
* OPFS sync access needs a worker/pthread + cross-origin isolation, so 'auto' only
|
|
89
|
+
* attempts it when isolated (the single-threaded build would otherwise trap).
|
|
90
|
+
* Returns whether the resulting mount is persistent.
|
|
91
|
+
*/
|
|
92
|
+
function mountWorkingDir(Module: any, persist: EngineConfig['persist']): { persistent: boolean } {
|
|
93
|
+
const isolated = typeof crossOriginIsolated !== 'undefined' && crossOriginIsolated;
|
|
94
|
+
const attemptOpfs = persist === 'opfs' || (persist === undefined && isolated);
|
|
95
|
+
|
|
96
|
+
if (attemptOpfs && typeof Module.ccall === 'function') {
|
|
97
|
+
try {
|
|
98
|
+
const rc = Module.ccall('web_mount_opfs', 'number', ['string'], [WORK_ROOT]);
|
|
99
|
+
if (rc === 0) return { persistent: true };
|
|
100
|
+
} catch {
|
|
101
|
+
/* fall through to in-memory */
|
|
102
|
+
}
|
|
103
|
+
if (persist === 'opfs') {
|
|
104
|
+
console.warn('[rsdkv4] OPFS requested but unavailable — using in-memory WASMFS');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
ensureDir(Module, WORK_ROOT);
|
|
108
|
+
return { persistent: false };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True if `path` exists in the (mounted) filesystem. */
|
|
112
|
+
function fileExists(Module: any, path: string): boolean {
|
|
113
|
+
try {
|
|
114
|
+
Module.FS.stat(path);
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Delete a file if present; returns true when something was removed. */
|
|
122
|
+
function deleteFileIfExists(Module: any, path: string): boolean {
|
|
123
|
+
if (!fileExists(Module, path)) return false;
|
|
124
|
+
try {
|
|
125
|
+
Module.FS.unlink(path);
|
|
126
|
+
return true;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** RSDKv4-specific bridge for the launcher's debug/stage-select UI. */
|
|
133
|
+
export interface RsdkDevMenuBridge {
|
|
134
|
+
getStageList(): Array<{ name: string; stages: Array<{ name: string }> }>;
|
|
135
|
+
loadStage(categoryIdx: number, stageIdx: number): void;
|
|
136
|
+
setPaused(paused: boolean): void;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type Rsdkv4Instance = EngineInstance & {
|
|
140
|
+
devMenu: RsdkDevMenuBridge;
|
|
141
|
+
/** True when the working dir is OPFS-backed (persistent) rather than in-memory. */
|
|
142
|
+
persistent: boolean;
|
|
143
|
+
/** Relative storage namespace used under /data (e.g. "sonic1", "sonic2"). */
|
|
144
|
+
storageNamespace: string;
|
|
145
|
+
/** Remove persisted game files for this namespace only. */
|
|
146
|
+
purgeStorage(): { data: boolean; settings: boolean };
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Extra (engine-specific) config on top of the contract's EngineConfig: lazy asset
|
|
151
|
+
* providers, invoked only on a cache miss — e.g. to skip a large Data.rsdk fetch
|
|
152
|
+
* when it's already persisted in OPFS.
|
|
153
|
+
*/
|
|
154
|
+
export type Rsdkv4LoadConfig = EngineConfig & {
|
|
155
|
+
dataProvider?: () => Promise<AssetData> | AssetData;
|
|
156
|
+
settingsProvider?: () => Promise<AssetData> | AssetData;
|
|
157
|
+
/**
|
|
158
|
+
* Per-game storage folder under /data used for OPFS/WASMFS files.
|
|
159
|
+
* Examples: "sonic1", "sonic2", "my-pack/v1".
|
|
160
|
+
*/
|
|
161
|
+
storageNamespace?: string;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Boot the RSDKv4 engine. */
|
|
165
|
+
export async function load(config: Rsdkv4LoadConfig): Promise<Rsdkv4Instance> {
|
|
166
|
+
const { canvas, assets, onEvent } = config;
|
|
167
|
+
const options = config.options as Rsdkv4Options | undefined;
|
|
168
|
+
if (!canvas) throw new Error('rsdkv4: config.canvas is required');
|
|
169
|
+
|
|
170
|
+
// Emscripten's SDL2 port locates the canvas via document.querySelector('#canvas').
|
|
171
|
+
if (canvas.id !== 'canvas') canvas.id = 'canvas';
|
|
172
|
+
|
|
173
|
+
const emit = (e: Parameters<NonNullable<EngineConfig['onEvent']>>[0]) => {
|
|
174
|
+
try { onEvent?.(e); } catch { /* host handler must not break us */ }
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const jsUrl = config.jsUrl ?? new URL('./rsdkv4.js', import.meta.url).href;
|
|
178
|
+
const wasmUrl = config.wasmUrl ?? new URL('./rsdkv4.wasm', import.meta.url).href;
|
|
179
|
+
|
|
180
|
+
// RSDKv4 keyboard bindings are the shared input script's default preset.
|
|
181
|
+
if (typeof window !== 'undefined') (window as any).__gamepadKeyMap = manifest.input;
|
|
182
|
+
|
|
183
|
+
const mod: any = await import(/* @vite-ignore */ jsUrl);
|
|
184
|
+
const createRSDKv4 = mod.default;
|
|
185
|
+
|
|
186
|
+
const Module: any = await createRSDKv4({
|
|
187
|
+
canvas,
|
|
188
|
+
noInitialRun: true, // built with -sINVOKE_RUN=0; we mount data before main()
|
|
189
|
+
locateFile: (path: string) => (path.endsWith('.wasm') ? wasmUrl : path),
|
|
190
|
+
print: (...a: unknown[]) => console.log('[rsdkv4]', ...a),
|
|
191
|
+
printErr: (...a: unknown[]) => console.error('[rsdkv4]', ...a),
|
|
192
|
+
onAbort: (reason: unknown) =>
|
|
193
|
+
emit({ type: 'error', error: new Error(`rsdkv4 aborted: ${reason}`) }),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Mount the working dir first, so we can see what's already persisted (OPFS)
|
|
197
|
+
// before deciding whether to pull assets.
|
|
198
|
+
const { persistent } = mountWorkingDir(Module, config.persist);
|
|
199
|
+
const storageNamespace = normalizeStorageNamespace(config.storageNamespace);
|
|
200
|
+
const workDir = `${WORK_ROOT}/${storageNamespace}`;
|
|
201
|
+
ensureDir(Module, workDir);
|
|
202
|
+
|
|
203
|
+
const dataPath = `${workDir}/Data.rsdk`;
|
|
204
|
+
const settingsPath = `${workDir}/settings.ini`;
|
|
205
|
+
|
|
206
|
+
// Data.rsdk — precedence: explicit asset > already persisted (skip fetch) > lazy provider.
|
|
207
|
+
let dataBytes = toUint8(assets?.data);
|
|
208
|
+
if (!dataBytes) {
|
|
209
|
+
if (fileExists(Module, dataPath)) {
|
|
210
|
+
console.log(
|
|
211
|
+
`[rsdkv4] Data.rsdk already in persistent storage (${storageNamespace}) — skipping fetch`,
|
|
212
|
+
);
|
|
213
|
+
} else if (config.dataProvider) {
|
|
214
|
+
dataBytes = toUint8(await config.dataProvider());
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (dataBytes) {
|
|
218
|
+
Module.FS.writeFile(dataPath, dataBytes);
|
|
219
|
+
} else if (!fileExists(Module, dataPath)) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
'rsdkv4: no Data.rsdk — provide assets.data, a dataProvider, or a persisted copy (OPFS)',
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// settings.ini — explicit asset > persisted > lazy provider > generated from options.
|
|
226
|
+
let settingsBytes = toUint8(assets?.settings);
|
|
227
|
+
if (!settingsBytes && !fileExists(Module, settingsPath) && config.settingsProvider) {
|
|
228
|
+
settingsBytes = toUint8(await config.settingsProvider());
|
|
229
|
+
}
|
|
230
|
+
if (settingsBytes) {
|
|
231
|
+
Module.FS.writeFile(settingsPath, settingsBytes);
|
|
232
|
+
} else if (!fileExists(Module, settingsPath)) {
|
|
233
|
+
Module.FS.writeFile(settingsPath, new TextEncoder().encode(buildSettingsIni(options)));
|
|
234
|
+
}
|
|
235
|
+
// else: keep the persisted settings.ini
|
|
236
|
+
|
|
237
|
+
Module.FS.chdir(workDir);
|
|
238
|
+
|
|
239
|
+
const setPaused = (paused: boolean) => {
|
|
240
|
+
if (typeof Module.web_devmenu_set_paused === 'function') Module.web_devmenu_set_paused(paused);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const devMenu: RsdkDevMenuBridge = {
|
|
244
|
+
getStageList() {
|
|
245
|
+
if (typeof Module.web_devmenu_get_stage_list !== 'function') return [];
|
|
246
|
+
try {
|
|
247
|
+
return JSON.parse(Module.web_devmenu_get_stage_list());
|
|
248
|
+
} catch (e) {
|
|
249
|
+
console.error('[rsdkv4] getStageList failed', e);
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
loadStage(categoryIdx, stageIdx) {
|
|
254
|
+
if (typeof Module.web_devmenu_load_stage === 'function') {
|
|
255
|
+
Module.web_devmenu_load_stage(categoryIdx | 0, stageIdx | 0);
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
setPaused,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// Run main() from the OPFS/WASMFS working dir. simulate_infinite_loop schedules
|
|
262
|
+
// the rAF loop and returns via a benign unwind Emscripten swallows.
|
|
263
|
+
Module.callMain(['UsingCWD']);
|
|
264
|
+
emit({ type: 'ready' });
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
start() {},
|
|
268
|
+
pause() { setPaused(true); },
|
|
269
|
+
resume() { setPaused(false); },
|
|
270
|
+
reset() {
|
|
271
|
+
throw new Error('rsdkv4: reset() is not supported — destroy() and load() again');
|
|
272
|
+
},
|
|
273
|
+
setInput(preset) {
|
|
274
|
+
if (typeof window !== 'undefined') {
|
|
275
|
+
(window as any).__gamepadKeyMap = preset ?? manifest.input;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
destroy() {
|
|
279
|
+
try { Module.pauseMainLoop?.(); } catch { /* noop */ }
|
|
280
|
+
try { setPaused(true); } catch { /* noop */ }
|
|
281
|
+
},
|
|
282
|
+
devMenu,
|
|
283
|
+
persistent,
|
|
284
|
+
storageNamespace,
|
|
285
|
+
purgeStorage() {
|
|
286
|
+
return {
|
|
287
|
+
data: deleteFileIfExists(Module, dataPath),
|
|
288
|
+
settings: deleteFileIfExists(Module, settingsPath),
|
|
289
|
+
};
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export default { manifest, load };
|