@wasm-gaming/rsdkv3-wasm 0.1.2 → 0.1.5
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 +58 -65
- package/dist/rsdkv3/engine-specs.js +382 -0
- package/dist/rsdkv3/rsdkv3.js +10877 -0
- package/dist/rsdkv3/rsdkv3.options.d.ts +63 -15
- package/dist/rsdkv3/rsdkv3.options.d.ts.map +1 -1
- package/dist/rsdkv3/rsdkv3.options.js +36 -37
- package/dist/rsdkv3/rsdkv3.options.js.map +1 -1
- package/dist/rsdkv3/rsdkv3.sdk.d.ts +74 -33
- package/dist/rsdkv3/rsdkv3.sdk.d.ts.map +1 -1
- package/dist/rsdkv3/rsdkv3.sdk.js +355 -184
- package/dist/rsdkv3/rsdkv3.sdk.js.map +1 -1
- package/dist/rsdkv3/rsdkv3.wasm +0 -0
- package/package.json +6 -15
- package/src/rsdkv3.options.ts +99 -51
- package/src/rsdkv3.sdk.ts +407 -200
- package/dist/rsdkv3/rsdkv3.manifest.d.ts +0 -4
- package/dist/rsdkv3/rsdkv3.manifest.d.ts.map +0 -1
- package/dist/rsdkv3/rsdkv3.manifest.js +0 -39
- package/dist/rsdkv3/rsdkv3.manifest.js.map +0 -1
- package/dist/rsdkv3/wasm-specs.d.ts +0 -71
- package/dist/rsdkv3/wasm-specs.d.ts.map +0 -1
- package/dist/rsdkv3/wasm-specs.js +0 -2
- package/dist/rsdkv3/wasm-specs.js.map +0 -1
- package/src/rsdkv3.manifest.ts +0 -44
- package/src/wasm-specs.ts +0 -72
package/src/rsdkv3.sdk.ts
CHANGED
|
@@ -1,258 +1,465 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The engine package's entry point: an `EngineSDK` for RSDKv3 (Sonic CD).
|
|
3
|
+
*
|
|
4
|
+
* ```js
|
|
5
|
+
* import Rsdkv3SDK from '@wasm-gaming/rsdkv3-wasm';
|
|
6
|
+
*
|
|
7
|
+
* const play = await new Rsdkv3SDK({ config: { soundtrack: 1 } })
|
|
8
|
+
* .assets({ data: () => readDataRsdk() }) // bytes, or a thunk
|
|
9
|
+
* .storage({ namespace: 'sonic-cd' }) // → OPFS rsdkv3/sonic-cd/
|
|
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 `rsdkv3.wasm`: `Data.rsdk` is written into the filesystem at
|
|
19
|
+
* runtime, never baked in. The working dir `/data/rsdkv3/<namespace>` is in memory;
|
|
20
|
+
* the SDK seeds `SData.bin` from OPFS `rsdkv3/<namespace>/` before boot and mirrors it
|
|
21
|
+
* back when it changes (every 10 s, on pause, on page hide and on destroy).
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
EnginePlayBase,
|
|
25
|
+
EngineSDKBase,
|
|
26
|
+
EventEmitter,
|
|
27
|
+
type EngineInit,
|
|
28
|
+
type EngineSDKConstructor,
|
|
29
|
+
type EventsOf,
|
|
30
|
+
type PayloadKind,
|
|
31
|
+
} from '@wasm-gaming/engine-specs';
|
|
32
|
+
import {
|
|
33
|
+
RSDKV3_SPEC,
|
|
34
|
+
RSDKV3_VIDEO,
|
|
35
|
+
type Rsdkv3Asset,
|
|
36
|
+
type Rsdkv3Config,
|
|
37
|
+
type Rsdkv3Options,
|
|
38
|
+
type Rsdkv3Payloads,
|
|
39
|
+
} from './rsdkv3.options.js';
|
|
12
40
|
|
|
13
|
-
export {
|
|
41
|
+
export type {
|
|
42
|
+
Rsdkv3Asset,
|
|
43
|
+
Rsdkv3Assets,
|
|
44
|
+
Rsdkv3Config,
|
|
45
|
+
Rsdkv3Events,
|
|
46
|
+
Rsdkv3Options,
|
|
47
|
+
Rsdkv3Payloads,
|
|
48
|
+
Rsdkv3Storage,
|
|
49
|
+
} from './rsdkv3.options.js';
|
|
50
|
+
export { RSDKV3_SPEC, RSDKV3_VIDEO } from './rsdkv3.options.js';
|
|
14
51
|
|
|
15
|
-
const
|
|
52
|
+
const ENGINE_ID = 'rsdkv3';
|
|
16
53
|
const DEFAULT_STORAGE_NAMESPACE = 'default';
|
|
54
|
+
const WORK_ROOT = '/data';
|
|
55
|
+
const SAVE_FILE = 'SData.bin';
|
|
56
|
+
const SAVE_INTERVAL_MS = 10_000;
|
|
57
|
+
|
|
58
|
+
/** The Emscripten module this build produces (`createRSDKv3` from rsdkv3.js). */
|
|
59
|
+
interface Rsdkv3Module {
|
|
60
|
+
FS: {
|
|
61
|
+
mkdir(path: string): void;
|
|
62
|
+
writeFile(path: string, data: Uint8Array): void;
|
|
63
|
+
readFile(path: string): Uint8Array;
|
|
64
|
+
chdir(path: string): void;
|
|
65
|
+
};
|
|
66
|
+
callMain(args: string[]): void;
|
|
67
|
+
pauseMainLoop?: () => void;
|
|
68
|
+
web_devmenu_get_stage_list?: () => string;
|
|
69
|
+
web_devmenu_load_stage?: (category: number, stage: number) => void;
|
|
70
|
+
web_devmenu_set_paused?: (paused: boolean) => void;
|
|
71
|
+
SDL2?: { audioContext?: AudioContext };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Rsdkv3Factory = (overrides: Record<string, unknown>) => Promise<Rsdkv3Module>;
|
|
75
|
+
|
|
76
|
+
/** The launcher's handle on RSDKv3's stage list, for drawing its own dev menu. */
|
|
77
|
+
export interface RsdkDevMenuBridge {
|
|
78
|
+
getStageList(): Array<{ name: string; stages: Array<{ name: string }> }>;
|
|
79
|
+
loadStage(category: number, stage: number): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Where an engine keeps one namespace: `<engineId>/<namespace>`, relative to the OPFS
|
|
84
|
+
* root. Identical in every wasm-gaming engine, written to move verbatim into
|
|
85
|
+
* engine-specs: segments cleaned, `.` and `..` dropped, anything outside
|
|
86
|
+
* `[A-Za-z0-9._-]` → `_`, nothing usable → `default`; a leading `<engineId>/` is
|
|
87
|
+
* stripped once.
|
|
88
|
+
*/
|
|
89
|
+
export function storagePath(
|
|
90
|
+
engineId: string,
|
|
91
|
+
namespace: unknown,
|
|
92
|
+
): { namespace: string; path: string; prefixed: boolean } {
|
|
93
|
+
const segments =
|
|
94
|
+
typeof namespace === 'string'
|
|
95
|
+
? namespace
|
|
96
|
+
.split('/')
|
|
97
|
+
.map((segment) => segment.trim())
|
|
98
|
+
.filter((segment) => segment && segment !== '.' && segment !== '..')
|
|
99
|
+
.map((segment) => segment.replace(/[^A-Za-z0-9._-]/g, '_'))
|
|
100
|
+
: [];
|
|
101
|
+
const prefixed = segments.length > 1 && segments[0] === engineId;
|
|
102
|
+
if (prefixed) segments.shift();
|
|
103
|
+
const cleaned = segments.join('/') || DEFAULT_STORAGE_NAMESPACE;
|
|
104
|
+
return { namespace: cleaned, path: `${engineId}/${cleaned}`, prefixed };
|
|
105
|
+
}
|
|
17
106
|
|
|
18
|
-
/**
|
|
19
|
-
function buildSettingsIni(
|
|
20
|
-
const o = { ...DEFAULT_RSDKV3_OPTIONS, ...options };
|
|
107
|
+
/** Serialise the engine config into RSDKv3's settings.ini. */
|
|
108
|
+
export function buildSettingsIni(config: Rsdkv3Config): string {
|
|
21
109
|
return [
|
|
22
110
|
'[Dev]',
|
|
23
|
-
`EngineDebugMode=${
|
|
24
|
-
`DevMenu=${
|
|
111
|
+
`EngineDebugMode=${config.engineDebugMode ? 'true' : 'false'}`,
|
|
112
|
+
`DevMenu=${config.devMenu ? 'true' : 'false'}`,
|
|
25
113
|
'',
|
|
26
114
|
'[Game]',
|
|
27
|
-
`Language=${
|
|
28
|
-
`DisableTouchControls=${
|
|
115
|
+
`Language=${(config.language ?? 0) | 0}`,
|
|
116
|
+
`DisableTouchControls=${config.disableTouchControls === false ? 'false' : 'true'}`,
|
|
29
117
|
'',
|
|
30
118
|
'[Video]',
|
|
31
119
|
'Windowed=true',
|
|
32
120
|
'Borderless=false',
|
|
33
121
|
'ExclusiveFS=false',
|
|
34
|
-
`VSync=${
|
|
122
|
+
`VSync=${config.vsync ? 'true' : 'false'}`,
|
|
35
123
|
'WindowScale=2',
|
|
36
|
-
|
|
37
|
-
|
|
124
|
+
`ScreenWidth=${RSDKV3_VIDEO.width}`,
|
|
125
|
+
`ScreenHeight=${RSDKV3_VIDEO.height}`,
|
|
38
126
|
'RefreshRate=60',
|
|
39
127
|
'',
|
|
40
128
|
'[Audio]',
|
|
41
129
|
'BGMVolume=1.000000',
|
|
42
130
|
'SFXVolume=1.000000',
|
|
43
|
-
`Soundtrack=${
|
|
131
|
+
`Soundtrack=${(config.soundtrack ?? 0) | 0}`,
|
|
44
132
|
'',
|
|
45
133
|
].join('\n');
|
|
46
134
|
}
|
|
47
135
|
|
|
48
|
-
function
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
136
|
+
async function opfsDir(path: string, create: boolean): Promise<FileSystemDirectoryHandle | null> {
|
|
137
|
+
if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return null;
|
|
138
|
+
try {
|
|
139
|
+
let dir = await navigator.storage.getDirectory();
|
|
140
|
+
for (const segment of path.split('/').filter(Boolean)) {
|
|
141
|
+
dir = await dir.getDirectoryHandle(segment, { create });
|
|
142
|
+
}
|
|
143
|
+
return dir;
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
55
147
|
}
|
|
56
148
|
|
|
57
|
-
|
|
58
|
-
function
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
.filter(Boolean)
|
|
65
|
-
.map((segment) => segment.replace(/[^A-Za-z0-9._-]/g, '_'))
|
|
66
|
-
.filter(Boolean)
|
|
67
|
-
.join('/');
|
|
149
|
+
async function resolveAsset(asset: Rsdkv3Asset | undefined): Promise<Uint8Array | null> {
|
|
150
|
+
const value = typeof asset === 'function' ? await asset() : asset;
|
|
151
|
+
if (value == null) return null;
|
|
152
|
+
if (value instanceof Uint8Array) return value;
|
|
153
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
154
|
+
throw new TypeError('rsdkv3: an asset must be a Uint8Array, an ArrayBuffer or a thunk returning one');
|
|
155
|
+
}
|
|
68
156
|
|
|
69
|
-
|
|
157
|
+
function sameBytes(a: Uint8Array | null, b: Uint8Array): boolean {
|
|
158
|
+
if (!a || a.length !== b.length) return false;
|
|
159
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
160
|
+
return true;
|
|
70
161
|
}
|
|
71
162
|
|
|
72
|
-
|
|
73
|
-
function ensureDir(Module: any, path: string): void {
|
|
74
|
-
const parts = path.split('/').filter(Boolean);
|
|
163
|
+
function mkdirp(Module: Rsdkv3Module, path: string): void {
|
|
75
164
|
let current = '';
|
|
76
|
-
for (const part of
|
|
165
|
+
for (const part of path.split('/').filter(Boolean)) {
|
|
77
166
|
current += `/${part}`;
|
|
78
167
|
try {
|
|
79
168
|
Module.FS.mkdir(current);
|
|
80
169
|
} catch {
|
|
81
|
-
/*
|
|
170
|
+
/* exists */
|
|
82
171
|
}
|
|
83
172
|
}
|
|
84
173
|
}
|
|
85
174
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
175
|
+
/** The live engine: one booted RSDKv3 and its session. */
|
|
176
|
+
export class Rsdkv3Play extends EnginePlayBase<Rsdkv3Payloads> {
|
|
177
|
+
#module: Rsdkv3Module | null = null;
|
|
178
|
+
#destroyed = false;
|
|
179
|
+
#pauseOwner: string | null = null;
|
|
92
180
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
181
|
+
#canvas: HTMLCanvasElement | null = null;
|
|
182
|
+
#ownsCanvas = false;
|
|
183
|
+
#box: HTMLElement | null = null;
|
|
184
|
+
#boxObserver: ResizeObserver | null = null;
|
|
185
|
+
|
|
186
|
+
#namespace = DEFAULT_STORAGE_NAMESPACE;
|
|
187
|
+
#storagePath = `${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`;
|
|
188
|
+
#workDir = `${WORK_ROOT}/${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`;
|
|
189
|
+
#persist = true;
|
|
190
|
+
#saveTimer: ReturnType<typeof setInterval> | null = null;
|
|
191
|
+
#lastSave: Uint8Array | null = null;
|
|
192
|
+
#flushing: Promise<void> | null = null;
|
|
193
|
+
#onHidden = (): void => {
|
|
194
|
+
if (document.visibilityState === 'hidden') void this.flushSaves();
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
constructor(init?: EngineInit<Rsdkv3Payloads>, events?: EventEmitter<EventsOf<Rsdkv3Payloads>>) {
|
|
198
|
+
super(RSDKV3_SPEC, init, events);
|
|
100
199
|
}
|
|
101
|
-
}
|
|
102
200
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
loadStage(categoryIdx: number, stageIdx: number): void;
|
|
107
|
-
setPaused(paused: boolean): void;
|
|
108
|
-
}
|
|
201
|
+
get namespace(): string {
|
|
202
|
+
return this.#namespace;
|
|
203
|
+
}
|
|
109
204
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
/** Relative storage namespace used under /data (e.g. "sonic-cd"). */
|
|
115
|
-
storageNamespace: string;
|
|
116
|
-
/** Remove files for this namespace only. */
|
|
117
|
-
purgeStorage(): { data: boolean; settings: boolean };
|
|
118
|
-
};
|
|
205
|
+
/** Where this session's files live, relative to the OPFS root: `rsdkv3/<namespace>`. */
|
|
206
|
+
get storagePath(): string {
|
|
207
|
+
return this.#storagePath;
|
|
208
|
+
}
|
|
119
209
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
* when it's already persisted in OPFS.
|
|
124
|
-
*/
|
|
125
|
-
export type Rsdkv3LoadConfig = EngineConfig & {
|
|
126
|
-
dataProvider?: () => Promise<AssetData> | AssetData;
|
|
127
|
-
settingsProvider?: () => Promise<AssetData> | AssetData;
|
|
128
|
-
/** Per-game storage folder under /data used for the in-memory filesystem. */
|
|
129
|
-
storageNamespace?: string;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
/** Boot the RSDKv3 engine. */
|
|
133
|
-
export async function load(config: Rsdkv3LoadConfig): Promise<Rsdkv3Instance> {
|
|
134
|
-
const { canvas, assets, onEvent } = config;
|
|
135
|
-
const options = config.options as Rsdkv3Options | undefined;
|
|
136
|
-
if (!canvas) throw new Error('rsdkv3: config.canvas is required');
|
|
137
|
-
|
|
138
|
-
// Emscripten's SDL2 port locates the canvas via document.querySelector('#canvas').
|
|
139
|
-
if (canvas.id !== 'canvas') canvas.id = 'canvas';
|
|
140
|
-
|
|
141
|
-
const emit = (e: Parameters<NonNullable<EngineConfig['onEvent']>>[0]) => {
|
|
142
|
-
try { onEvent?.(e); } catch { /* host handler must not break us */ }
|
|
143
|
-
};
|
|
210
|
+
get pausedBy(): string | null {
|
|
211
|
+
return this.#pauseOwner;
|
|
212
|
+
}
|
|
144
213
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
onAbort: (reason: unknown) =>
|
|
161
|
-
emit({ type: 'error', error: new Error(`rsdkv3 aborted: ${reason}`) }),
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
const storageNamespace = normalizeStorageNamespace(config.storageNamespace);
|
|
165
|
-
const { persistent, workDir } = mountWorkingDir(Module, storageNamespace);
|
|
166
|
-
const dataPath = `${workDir}/Data.rsdk`;
|
|
167
|
-
const settingsPath = `${workDir}/settings.ini`;
|
|
168
|
-
|
|
169
|
-
// Data.rsdk — precedence: explicit asset > lazy provider > existing file.
|
|
170
|
-
let dataBytes = toUint8(assets?.data);
|
|
171
|
-
if (!dataBytes && config.dataProvider) {
|
|
172
|
-
dataBytes = toUint8(await config.dataProvider());
|
|
214
|
+
/** RSDKv3's stage list, for a host that draws its own stage select. */
|
|
215
|
+
get devMenu(): RsdkDevMenuBridge {
|
|
216
|
+
const Module = (): Rsdkv3Module | null => this.#module;
|
|
217
|
+
return {
|
|
218
|
+
getStageList() {
|
|
219
|
+
try {
|
|
220
|
+
return JSON.parse(Module()?.web_devmenu_get_stage_list?.() ?? '[]');
|
|
221
|
+
} catch {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
loadStage(category: number, stage: number) {
|
|
226
|
+
Module()?.web_devmenu_load_stage?.(category | 0, stage | 0);
|
|
227
|
+
},
|
|
228
|
+
};
|
|
173
229
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Freeze the game and its sound. The first caller owns the pause; only that
|
|
233
|
+
* owner's {@link resume} lifts it. The save is flushed.
|
|
234
|
+
*/
|
|
235
|
+
pause(owner = 'host'): void {
|
|
236
|
+
const Module = this.#module;
|
|
237
|
+
if (!Module || this.#pauseOwner) return;
|
|
238
|
+
this.#pauseOwner = owner;
|
|
239
|
+
Module.web_devmenu_set_paused?.(true);
|
|
240
|
+
void Module.SDL2?.audioContext?.suspend();
|
|
241
|
+
void this.flushSaves();
|
|
242
|
+
this.emit('pause', { owner });
|
|
178
243
|
}
|
|
179
244
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
245
|
+
resume(owner = 'host'): void {
|
|
246
|
+
const Module = this.#module;
|
|
247
|
+
if (!Module || this.#pauseOwner !== owner) return;
|
|
248
|
+
this.#pauseOwner = null;
|
|
249
|
+
void Module.SDL2?.audioContext?.resume();
|
|
250
|
+
Module.web_devmenu_set_paused?.(false);
|
|
251
|
+
this.emit('resume', { owner });
|
|
184
252
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
253
|
+
|
|
254
|
+
/** Copy `SData.bin` to OPFS `rsdkv3/<namespace>/` if it changed; emits `saves`. */
|
|
255
|
+
flushSaves(): Promise<void> {
|
|
256
|
+
this.#flushing ??= this.#flush().finally(() => {
|
|
257
|
+
this.#flushing = null;
|
|
258
|
+
});
|
|
259
|
+
return this.#flushing;
|
|
189
260
|
}
|
|
190
|
-
// else: keep existing settings.ini in memory FS
|
|
191
261
|
|
|
192
|
-
|
|
262
|
+
// ---- Contract hooks ----
|
|
193
263
|
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
264
|
+
protected override mounted(target: HTMLElement): void {
|
|
265
|
+
if (target instanceof HTMLCanvasElement) {
|
|
266
|
+
this.#canvas = target;
|
|
267
|
+
this.#ownsCanvas = false;
|
|
268
|
+
this.#box = target.parentElement;
|
|
269
|
+
} else {
|
|
270
|
+
this.#canvas = document.createElement('canvas');
|
|
271
|
+
this.#ownsCanvas = true;
|
|
272
|
+
this.#box = target;
|
|
273
|
+
target.appendChild(this.#canvas);
|
|
274
|
+
}
|
|
275
|
+
// Emscripten's SDL2 port finds its window with querySelector('#canvas').
|
|
276
|
+
this.#canvas.id = 'canvas';
|
|
277
|
+
this.#canvas.tabIndex = 0;
|
|
278
|
+
this.#canvas.style.display = 'block';
|
|
279
|
+
this.#canvas.style.imageRendering = 'pixelated';
|
|
280
|
+
this.#canvas.addEventListener('contextmenu', (event) => event.preventDefault());
|
|
281
|
+
if (this.#ownsCanvas && this.#box) {
|
|
282
|
+
this.#boxObserver?.disconnect();
|
|
283
|
+
this.#boxObserver = new ResizeObserver(() => this.#fit());
|
|
284
|
+
this.#boxObserver.observe(this.#box);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
197
287
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
288
|
+
protected override async open(_options: Rsdkv3Options): Promise<void> {
|
|
289
|
+
if (this.#destroyed || this.#module) {
|
|
290
|
+
// RSDKv3 cannot restart in process: main() runs once and owns the loop.
|
|
291
|
+
throw new Error('rsdkv3: the engine cannot be restarted in process — destroy() and start a new page (or iframe)');
|
|
292
|
+
}
|
|
293
|
+
await this.#boot();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
protected override patched(_kind: PayloadKind, _patch: object): void {
|
|
297
|
+
// Nothing in RSDKV3_SPEC is live: settings.ini is read once, at boot.
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
protected override async close(): Promise<void> {
|
|
301
|
+
if (this.#saveTimer) clearInterval(this.#saveTimer);
|
|
302
|
+
this.#saveTimer = null;
|
|
303
|
+
document.removeEventListener('visibilitychange', this.#onHidden);
|
|
304
|
+
window.removeEventListener('pagehide', this.#onHidden);
|
|
305
|
+
const Module = this.#module;
|
|
306
|
+
if (Module) {
|
|
307
|
+
await this.flushSaves();
|
|
201
308
|
try {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
309
|
+
Module.web_devmenu_set_paused?.(true);
|
|
310
|
+
Module.pauseMainLoop?.();
|
|
311
|
+
} catch {
|
|
312
|
+
/* already stopped */
|
|
206
313
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
314
|
+
try {
|
|
315
|
+
await Module.SDL2?.audioContext?.close();
|
|
316
|
+
} catch {
|
|
317
|
+
/* already closed */
|
|
211
318
|
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
|
|
319
|
+
}
|
|
320
|
+
this.#boxObserver?.disconnect();
|
|
321
|
+
this.#boxObserver = null;
|
|
322
|
+
if (this.#ownsCanvas) this.#canvas?.remove();
|
|
323
|
+
this.#canvas = null;
|
|
324
|
+
this.#module = null;
|
|
325
|
+
this.#pauseOwner = null;
|
|
326
|
+
this.#destroyed = true;
|
|
327
|
+
}
|
|
215
328
|
|
|
216
|
-
//
|
|
217
|
-
// the rAF loop and returns via a benign unwind Emscripten swallows.
|
|
218
|
-
Module.callMain(['UsingCWD']);
|
|
219
|
-
emit({ type: 'ready' });
|
|
220
|
-
|
|
221
|
-
return {
|
|
222
|
-
start() {},
|
|
223
|
-
pause() { setPaused(true); },
|
|
224
|
-
resume() { setPaused(false); },
|
|
225
|
-
reset() {
|
|
226
|
-
throw new Error('rsdkv3: reset() is not supported — destroy() and load() again');
|
|
227
|
-
},
|
|
228
|
-
setInput(map: InputPreset | KeyMap) {
|
|
229
|
-
if (typeof window !== 'undefined') {
|
|
230
|
-
(window as any).__gamepadKeyMap = map;
|
|
231
|
-
}
|
|
232
|
-
},
|
|
233
|
-
destroy() {
|
|
234
|
-
try { Module.pauseMainLoop?.(); } catch { /* noop */ }
|
|
235
|
-
try { setPaused(true); } catch { /* noop */ }
|
|
236
|
-
},
|
|
237
|
-
devMenu,
|
|
238
|
-
persistent,
|
|
239
|
-
storageNamespace,
|
|
240
|
-
purgeStorage() {
|
|
241
|
-
const deleteFileIfExists = (path: string): boolean => {
|
|
242
|
-
try {
|
|
243
|
-
Module.FS.unlink(path);
|
|
244
|
-
return true;
|
|
245
|
-
} catch {
|
|
246
|
-
return false;
|
|
247
|
-
}
|
|
248
|
-
};
|
|
329
|
+
// ---- Boot ----
|
|
249
330
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
331
|
+
async #boot(): Promise<void> {
|
|
332
|
+
const config = await this.#values<Rsdkv3Config>('config');
|
|
333
|
+
const assets = await this.#values<{ data?: Rsdkv3Asset; settings?: Rsdkv3Asset }>('assets');
|
|
334
|
+
const storage = await this.#values<{ namespace?: string; persist?: 'opfs' | null }>('storage');
|
|
335
|
+
if (!this.#canvas) throw new Error('rsdkv3: mount() an element (or a canvas) before start()');
|
|
336
|
+
|
|
337
|
+
const data = await resolveAsset(assets.data);
|
|
338
|
+
if (!data) throw new Error('rsdkv3: no Data.rsdk — hand one over as assets.data (bytes or a thunk)');
|
|
339
|
+
const settings = await resolveAsset(assets.settings);
|
|
340
|
+
|
|
341
|
+
const resolved = storagePath(ENGINE_ID, storage.namespace);
|
|
342
|
+
if (resolved.prefixed) {
|
|
343
|
+
console.warn(`[rsdkv3] storage.namespace no longer needs the "${ENGINE_ID}/" prefix. Pass "${resolved.namespace}".`);
|
|
344
|
+
}
|
|
345
|
+
this.#namespace = resolved.namespace;
|
|
346
|
+
this.#storagePath = resolved.path;
|
|
347
|
+
this.#workDir = `${WORK_ROOT}/${resolved.path}`;
|
|
348
|
+
this.#persist = storage.persist !== null;
|
|
349
|
+
|
|
350
|
+
const jsUrl = config.jsUrl ?? new URL('./rsdkv3.js', import.meta.url).href;
|
|
351
|
+
const wasmUrl = config.wasmUrl ?? new URL('./rsdkv3.wasm', import.meta.url).href;
|
|
352
|
+
const { default: createRSDKv3 } = (await import(/* @vite-ignore */ jsUrl)) as { default: Rsdkv3Factory };
|
|
353
|
+
|
|
354
|
+
let failed: ((error: Error) => void) | null = null;
|
|
355
|
+
const Module = await createRSDKv3({
|
|
356
|
+
canvas: this.#canvas,
|
|
357
|
+
noInitialRun: true,
|
|
358
|
+
locateFile: (path: string) => (path.endsWith('.wasm') ? wasmUrl : new URL(path, jsUrl).href),
|
|
359
|
+
print: (text: string) => console.log('[rsdkv3]', text),
|
|
360
|
+
printErr: (text: string) => console.warn('[rsdkv3]', text),
|
|
361
|
+
onAbort: (what: unknown) => failed?.(new Error(`rsdkv3: aborted — ${String(what)}`)),
|
|
362
|
+
});
|
|
363
|
+
this.#module = Module;
|
|
364
|
+
failed = (error) => this.emit('error', error);
|
|
365
|
+
|
|
366
|
+
mkdirp(Module, this.#workDir);
|
|
367
|
+
Module.FS.writeFile(`${this.#workDir}/Data.rsdk`, data);
|
|
368
|
+
Module.FS.writeFile(
|
|
369
|
+
`${this.#workDir}/settings.ini`,
|
|
370
|
+
settings ?? new TextEncoder().encode(buildSettingsIni(config)),
|
|
371
|
+
);
|
|
372
|
+
if (this.#persist) await this.#restoreSave();
|
|
373
|
+
|
|
374
|
+
Module.FS.chdir(this.#workDir);
|
|
375
|
+
Module.callMain(['UsingCWD']);
|
|
376
|
+
|
|
377
|
+
if (this.#persist) {
|
|
378
|
+
this.#saveTimer = setInterval(() => void this.flushSaves(), SAVE_INTERVAL_MS);
|
|
379
|
+
document.addEventListener('visibilitychange', this.#onHidden);
|
|
380
|
+
window.addEventListener('pagehide', this.#onHidden);
|
|
381
|
+
}
|
|
382
|
+
this.#fit();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async #values<T>(kind: 'config' | 'assets' | 'storage'): Promise<T> {
|
|
386
|
+
const described = (await this[kind]()) as Record<string, { value: unknown }>;
|
|
387
|
+
const out: Record<string, unknown> = {};
|
|
388
|
+
for (const [key, property] of Object.entries(described)) out[key] = property.value;
|
|
389
|
+
return out as T;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---- Save mirror ----
|
|
393
|
+
|
|
394
|
+
async #restoreSave(): Promise<void> {
|
|
395
|
+
const dir = await opfsDir(this.#storagePath, false);
|
|
396
|
+
if (!dir || !this.#module) return;
|
|
397
|
+
try {
|
|
398
|
+
const file = await (await dir.getFileHandle(SAVE_FILE)).getFile();
|
|
399
|
+
if (file.size === 0) return;
|
|
400
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
401
|
+
this.#module.FS.writeFile(`${this.#workDir}/${SAVE_FILE}`, bytes);
|
|
402
|
+
this.#lastSave = bytes;
|
|
403
|
+
} catch {
|
|
404
|
+
/* no save yet */
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async #flush(): Promise<void> {
|
|
409
|
+
const Module = this.#module;
|
|
410
|
+
if (!Module || !this.#persist) return;
|
|
411
|
+
let bytes: Uint8Array<ArrayBuffer>;
|
|
412
|
+
try {
|
|
413
|
+
bytes = new Uint8Array(Module.FS.readFile(`${this.#workDir}/${SAVE_FILE}`));
|
|
414
|
+
} catch {
|
|
415
|
+
return; // the game has not saved yet
|
|
416
|
+
}
|
|
417
|
+
if (sameBytes(this.#lastSave, bytes)) return;
|
|
418
|
+
const dir = await opfsDir(this.#storagePath, true);
|
|
419
|
+
if (!dir) return;
|
|
420
|
+
try {
|
|
421
|
+
const writable = await (await dir.getFileHandle(SAVE_FILE, { create: true })).createWritable();
|
|
422
|
+
await writable.write(bytes);
|
|
423
|
+
await writable.close();
|
|
424
|
+
this.#lastSave = bytes;
|
|
425
|
+
this.emit('saves', { files: [SAVE_FILE] });
|
|
426
|
+
} catch (e) {
|
|
427
|
+
console.warn('[rsdkv3] could not persist the save', e);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ---- Picture ----
|
|
432
|
+
|
|
433
|
+
#fit(): void {
|
|
434
|
+
const canvas = this.#canvas;
|
|
435
|
+
const box = this.#box;
|
|
436
|
+
if (!canvas || !box || !this.#ownsCanvas) return;
|
|
437
|
+
const { width, height } = box.getBoundingClientRect();
|
|
438
|
+
if (!width || !height) return;
|
|
439
|
+
const scale = Math.min(width / RSDKV3_VIDEO.width, height / RSDKV3_VIDEO.height);
|
|
440
|
+
canvas.style.width = `${Math.floor(RSDKV3_VIDEO.width * scale)}px`;
|
|
441
|
+
canvas.style.height = `${Math.floor(RSDKV3_VIDEO.height * scale)}px`;
|
|
442
|
+
}
|
|
256
443
|
}
|
|
257
444
|
|
|
258
|
-
|
|
445
|
+
/** The factory a host constructs: static identity, the configuration chain, `start()`. */
|
|
446
|
+
export class Rsdkv3SDK extends EngineSDKBase<Rsdkv3Payloads> {
|
|
447
|
+
static readonly id = ENGINE_ID;
|
|
448
|
+
/** Kept in step with package.json by `node scripts/sync-version.mjs`. */
|
|
449
|
+
static readonly version = '0.1.5';
|
|
450
|
+
static readonly name = 'Retro Software Development Kit v3';
|
|
451
|
+
static readonly description =
|
|
452
|
+
'RSDKv3 (Sonic CD 2011 decompilation) compiled to WebAssembly. Ships none of the game: the player provides Data.rsdk, and it never leaves their browser.';
|
|
453
|
+
|
|
454
|
+
protected override createPlay(
|
|
455
|
+
init: EngineInit<Rsdkv3Payloads>,
|
|
456
|
+
events: EventEmitter<EventsOf<Rsdkv3Payloads>>,
|
|
457
|
+
): Rsdkv3Play {
|
|
458
|
+
return new Rsdkv3Play(init, events);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** CI's conformance line: if this class stops satisfying the contract, `make test` fails. */
|
|
463
|
+
export const RSDKV3_ENGINE: EngineSDKConstructor<Rsdkv3Payloads> = Rsdkv3SDK;
|
|
464
|
+
|
|
465
|
+
export default Rsdkv3SDK;
|