@wasm-gaming/rsdkv5-wasm 0.1.1 → 0.2.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.
- package/README.md +106 -6
- package/dist/rsdkv5/app/app.css +176 -0
- package/dist/rsdkv5/app/app.js +527 -0
- package/dist/rsdkv5/app/assets/player-knuckles.svg +757 -0
- package/dist/rsdkv5/app/assets/player-sonic.svg +720 -0
- package/dist/rsdkv5/app/assets/player-sonictails.svg +1414 -0
- package/dist/rsdkv5/app/assets/player-tails.svg +979 -0
- package/dist/rsdkv5/engine-specs.js +382 -0
- package/dist/rsdkv5/rsdkv5.js +1 -1
- package/dist/rsdkv5/rsdkv5.options.d.ts +61 -13
- package/dist/rsdkv5/rsdkv5.options.d.ts.map +1 -1
- package/dist/rsdkv5/rsdkv5.options.js +35 -21
- package/dist/rsdkv5/rsdkv5.options.js.map +1 -1
- package/dist/rsdkv5/rsdkv5.sdk.d.ts +137 -32
- package/dist/rsdkv5/rsdkv5.sdk.d.ts.map +1 -1
- package/dist/rsdkv5/rsdkv5.sdk.js +474 -181
- package/dist/rsdkv5/rsdkv5.sdk.js.map +1 -1
- package/dist/rsdkv5/rsdkv5.wasm +0 -0
- package/package.json +9 -15
- package/src/app/app.css +176 -0
- package/src/app/app.js +527 -0
- package/src/app/assets/player-knuckles.svg +757 -0
- package/src/app/assets/player-sonic.svg +720 -0
- package/src/app/assets/player-sonictails.svg +1414 -0
- package/src/app/assets/player-tails.svg +979 -0
- package/src/rsdkv5.options.ts +96 -43
- package/src/rsdkv5.sdk.ts +562 -203
- package/dist/manifest.json +0 -69
- package/dist/rsdkv5/rsdkv5.manifest.d.ts +0 -4
- package/dist/rsdkv5/rsdkv5.manifest.d.ts.map +0 -1
- package/dist/rsdkv5/rsdkv5.manifest.js +0 -39
- package/dist/rsdkv5/rsdkv5.manifest.js.map +0 -1
- package/src/rsdkv5.manifest.ts +0 -44
package/src/rsdkv5.sdk.ts
CHANGED
|
@@ -1,50 +1,223 @@
|
|
|
1
|
-
|
|
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
|
+
resumeMainLoop?: () => void;
|
|
75
|
+
web_devmenu_get_stage_list?: () => string;
|
|
76
|
+
web_devmenu_load_stage?: (category: number, stage: number) => void;
|
|
77
|
+
web_devmenu_set_paused?: (paused: boolean) => void;
|
|
78
|
+
_web_mania_save_status?: () => number;
|
|
79
|
+
_web_mania_start_slot?: (slot: number, character: number) => number;
|
|
80
|
+
_web_mania_characters?: () => number;
|
|
81
|
+
SDL2?: { audioContext?: AudioContext };
|
|
82
|
+
}
|
|
2
83
|
|
|
3
|
-
|
|
4
|
-
// exports `manifest` (declarative) and `load(config)` (imperative).
|
|
84
|
+
type Rsdkv5Factory = (overrides: Record<string, unknown>) => Promise<Rsdkv5Module>;
|
|
5
85
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
// settings.ini.
|
|
86
|
+
/** The launcher's handle on RSDKv5's stage list, for drawing its own dev menu. */
|
|
87
|
+
export interface RsdkDevMenuBridge {
|
|
88
|
+
getStageList(): Array<{ name: string; stages: Array<{ name: string }> }>;
|
|
89
|
+
loadStage(category: number, stage: number): void;
|
|
90
|
+
}
|
|
12
91
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
92
|
+
/** Mania's characters, by the `characterID` its save slots store. */
|
|
93
|
+
export const MANIA_CHARACTERS = ['Sonic & Tails', 'Sonic', 'Tails', 'Knuckles', 'Mighty', 'Ray'] as const;
|
|
94
|
+
|
|
95
|
+
/** Mania Mode's zones, by the `zoneID` its save slots store. */
|
|
96
|
+
export const MANIA_ZONES = [
|
|
97
|
+
'Green Hill', 'Chemical Plant', 'Studiopolis', 'Flying Battery', 'Press Garden', 'Stardust Speedway',
|
|
98
|
+
'Hydrocity', 'Mirage Saloon', 'Oil Ocean', 'Lava Reef', 'Metallic Madness', 'Titanic Monarch', 'Egg Reverie',
|
|
99
|
+
] as const;
|
|
100
|
+
|
|
101
|
+
/** One Mania Mode save slot, as `SaveData.bin` has it. */
|
|
102
|
+
export interface ManiaSaveSlot {
|
|
103
|
+
/** 0-7, the slot Mania numbers 1-8. */
|
|
104
|
+
slot: number;
|
|
105
|
+
state: 'empty' | 'progress' | 'complete';
|
|
106
|
+
/** Index into {@link MANIA_CHARACTERS}. */
|
|
107
|
+
character: number;
|
|
108
|
+
/** Index into {@link MANIA_ZONES}; meaningful while in progress. */
|
|
109
|
+
zone: number;
|
|
110
|
+
zoneName: string;
|
|
111
|
+
lives: number;
|
|
112
|
+
score: number;
|
|
113
|
+
/** How many of the seven Chaos Emeralds (the save keeps a bit mask). */
|
|
114
|
+
emeralds: number;
|
|
115
|
+
continues: number;
|
|
116
|
+
}
|
|
16
117
|
|
|
17
|
-
|
|
118
|
+
/** Mania Plus's eight regular save slots in `SaveData.bin` (Encore slots are elsewhere). */
|
|
119
|
+
export const MANIA_SAVE_SLOTS = 8;
|
|
120
|
+
const SLOT_BYTES = 0x400;
|
|
121
|
+
const SLOT_FIELDS = 0x58; // SaveRAM: the entity header Mania keeps before the fields
|
|
18
122
|
|
|
19
|
-
|
|
20
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Mania Mode's save slots from `SaveData.bin` (`globals->saveRAM`, int32 LE): slot `s`
|
|
125
|
+
* is the 0x400-byte block at `0x400 * s`, fields from +0x58. A missing or short file
|
|
126
|
+
* reads as empty slots.
|
|
127
|
+
*/
|
|
128
|
+
export function parseManiaSaves(bytes: Uint8Array | null): ManiaSaveSlot[] {
|
|
129
|
+
const view = bytes ? new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) : null;
|
|
130
|
+
return Array.from({ length: MANIA_SAVE_SLOTS }, (_, slot) => {
|
|
131
|
+
const base = slot * SLOT_BYTES + SLOT_FIELDS;
|
|
132
|
+
const field = (i: number): number => (view && base + i * 4 + 4 <= view.byteLength ? view.getInt32(base + i * 4, true) : 0);
|
|
133
|
+
const state = field(0) === 1 ? 'progress' : field(0) === 2 ? 'complete' : 'empty';
|
|
134
|
+
const character = Math.max(0, Math.min(MANIA_CHARACTERS.length - 1, field(1)));
|
|
135
|
+
const zone = field(2);
|
|
136
|
+
let emeralds = 0;
|
|
137
|
+
for (let mask = field(6) & 0x7f; mask; mask >>= 1) emeralds += mask & 1;
|
|
138
|
+
return {
|
|
139
|
+
slot,
|
|
140
|
+
state,
|
|
141
|
+
character: state === 'empty' ? 0 : character,
|
|
142
|
+
zone: state === 'empty' ? 0 : zone,
|
|
143
|
+
zoneName: state === 'complete' ? 'Complete' : state === 'empty' ? '' : (MANIA_ZONES[zone] ?? 'Unknown zone'),
|
|
144
|
+
lives: state === 'empty' ? 3 : field(3),
|
|
145
|
+
score: state === 'empty' ? 0 : field(4),
|
|
146
|
+
emeralds: state === 'empty' ? 0 : emeralds,
|
|
147
|
+
continues: state === 'empty' ? 0 : field(7),
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Mania Mode's save slots, read and started the way Mania's own save select does. */
|
|
153
|
+
export interface ManiaSaves {
|
|
154
|
+
/** True when this build has the bridge (older wasm builds don't). */
|
|
155
|
+
readonly available: boolean;
|
|
156
|
+
/** How many characters a new game can pick: 6 with Plus (Mighty, Ray), else 4. */
|
|
157
|
+
readonly characters: number;
|
|
158
|
+
/** The eight slots, from the save file as it is now. */
|
|
159
|
+
list(): ManiaSaveSlot[];
|
|
160
|
+
/**
|
|
161
|
+
* Resolves true once Mania has its save file loaded (it asks Mania to load it), false
|
|
162
|
+
* if that failed or took longer than `timeoutMs`.
|
|
163
|
+
*/
|
|
164
|
+
ready(timeoutMs?: number): Promise<boolean>;
|
|
165
|
+
/**
|
|
166
|
+
* Start a slot: an empty one is a new game with `character` (an index into
|
|
167
|
+
* {@link MANIA_CHARACTERS}), a saved one continues with its own. Mania writes the
|
|
168
|
+
* save and loads the stage; the pause is lifted. False when the save isn't ready.
|
|
169
|
+
*/
|
|
170
|
+
start(slot: number, character?: number): boolean;
|
|
171
|
+
}
|
|
21
172
|
|
|
22
173
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* `
|
|
27
|
-
*
|
|
174
|
+
* Where an engine keeps one namespace: `<engineId>/<namespace>`, relative to the OPFS
|
|
175
|
+
* root. Identical in every wasm-gaming engine, written to move verbatim into
|
|
176
|
+
* engine-specs: segments cleaned, `.` and `..` dropped, anything outside
|
|
177
|
+
* `[A-Za-z0-9._-]` → `_`, nothing usable → `default`; a leading `<engineId>/` is
|
|
178
|
+
* stripped once.
|
|
28
179
|
*/
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
180
|
+
export function storagePath(
|
|
181
|
+
engineId: string,
|
|
182
|
+
namespace: unknown,
|
|
183
|
+
): { namespace: string; path: string; prefixed: boolean } {
|
|
184
|
+
const segments =
|
|
185
|
+
typeof namespace === 'string'
|
|
186
|
+
? namespace
|
|
187
|
+
.split('/')
|
|
188
|
+
.map((segment) => segment.trim())
|
|
189
|
+
.filter((segment) => segment && segment !== '.' && segment !== '..')
|
|
190
|
+
.map((segment) => segment.replace(/[^A-Za-z0-9._-]/g, '_'))
|
|
191
|
+
: [];
|
|
192
|
+
const prefixed = segments.length > 1 && segments[0] === engineId;
|
|
193
|
+
if (prefixed) segments.shift();
|
|
194
|
+
const cleaned = segments.join('/') || DEFAULT_STORAGE_NAMESPACE;
|
|
195
|
+
return { namespace: cleaned, path: `${engineId}/${cleaned}`, prefixed };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Serialise the engine config into RSDKv5U's `Settings.ini` (capital S — the engine
|
|
200
|
+
* opens exactly that name, and the Emscripten FS is case-sensitive). The v5 dialect:
|
|
201
|
+
* lowercase keys, 0/1 booleans, `dataFile=` naming the pack relative to the working dir.
|
|
202
|
+
*/
|
|
203
|
+
export function buildSettingsIni(config: Rsdkv5Config): string {
|
|
204
|
+
const b = (v: boolean | undefined) => (v ? '1' : '0');
|
|
32
205
|
return [
|
|
33
206
|
'[Game]',
|
|
34
207
|
'dataFile=Data.rsdk',
|
|
35
|
-
`devMenu=${b(
|
|
36
|
-
`language=${
|
|
37
|
-
`region=${
|
|
208
|
+
`devMenu=${b(config.devMenu)}`,
|
|
209
|
+
`language=${(config.language ?? 0) | 0}`,
|
|
210
|
+
`region=${(config.region ?? -1) | 0}`,
|
|
38
211
|
'',
|
|
39
212
|
'[Video]',
|
|
40
213
|
'windowed=1',
|
|
41
214
|
'border=1',
|
|
42
215
|
'exclusiveFS=0',
|
|
43
|
-
`vsync=${b(
|
|
216
|
+
`vsync=${b(config.vsync ?? true)}`,
|
|
44
217
|
'tripleBuffering=0',
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
218
|
+
`pixWidth=${RSDKV5_VIDEO.width}`,
|
|
219
|
+
`winWidth=${RSDKV5_VIDEO.width}`,
|
|
220
|
+
`winHeight=${RSDKV5_VIDEO.height}`,
|
|
48
221
|
'fsWidth=0',
|
|
49
222
|
'fsHeight=0',
|
|
50
223
|
'refreshRate=60',
|
|
@@ -59,216 +232,402 @@ function buildSettingsIni(options: Rsdkv5Options = {}): string {
|
|
|
59
232
|
].join('\n');
|
|
60
233
|
}
|
|
61
234
|
|
|
62
|
-
function
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
235
|
+
async function opfsDir(path: string, create: boolean): Promise<FileSystemDirectoryHandle | null> {
|
|
236
|
+
if (typeof navigator === 'undefined' || !navigator.storage?.getDirectory) return null;
|
|
237
|
+
try {
|
|
238
|
+
let dir = await navigator.storage.getDirectory();
|
|
239
|
+
for (const segment of path.split('/').filter(Boolean)) {
|
|
240
|
+
dir = await dir.getDirectoryHandle(segment, { create });
|
|
241
|
+
}
|
|
242
|
+
return dir;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
69
246
|
}
|
|
70
247
|
|
|
71
|
-
|
|
72
|
-
function
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
.filter(Boolean)
|
|
79
|
-
.map((segment) => segment.replace(/[^A-Za-z0-9._-]/g, '_'))
|
|
80
|
-
.filter(Boolean)
|
|
81
|
-
.join('/');
|
|
248
|
+
async function resolveAsset(asset: Rsdkv5Asset | undefined): Promise<Uint8Array | null> {
|
|
249
|
+
const value = typeof asset === 'function' ? await asset() : asset;
|
|
250
|
+
if (value == null) return null;
|
|
251
|
+
if (value instanceof Uint8Array) return value;
|
|
252
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
253
|
+
throw new TypeError('rsdkv5: an asset must be a Uint8Array, an ArrayBuffer or a thunk returning one');
|
|
254
|
+
}
|
|
82
255
|
|
|
83
|
-
|
|
256
|
+
function sameBytes(a: Uint8Array | null, b: Uint8Array): boolean {
|
|
257
|
+
if (!a || a.length !== b.length) return false;
|
|
258
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
259
|
+
return true;
|
|
84
260
|
}
|
|
85
261
|
|
|
86
|
-
|
|
87
|
-
function ensureDir(Module: any, path: string): void {
|
|
88
|
-
const parts = path.split('/').filter(Boolean);
|
|
262
|
+
function mkdirp(Module: Rsdkv5Module, path: string): void {
|
|
89
263
|
let current = '';
|
|
90
|
-
for (const part of
|
|
264
|
+
for (const part of path.split('/').filter(Boolean)) {
|
|
91
265
|
current += `/${part}`;
|
|
92
266
|
try {
|
|
93
267
|
Module.FS.mkdir(current);
|
|
94
268
|
} catch {
|
|
95
|
-
/*
|
|
269
|
+
/* exists */
|
|
96
270
|
}
|
|
97
271
|
}
|
|
98
272
|
}
|
|
99
273
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
274
|
+
/** The live engine: one booted RSDKv5 and its session. */
|
|
275
|
+
export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
|
|
276
|
+
#module: Rsdkv5Module | null = null;
|
|
277
|
+
#destroyed = false;
|
|
278
|
+
#pauseOwner: string | null = null;
|
|
279
|
+
#muted = false;
|
|
280
|
+
|
|
281
|
+
#canvas: HTMLCanvasElement | null = null;
|
|
282
|
+
#ownsCanvas = false;
|
|
283
|
+
#box: HTMLElement | null = null;
|
|
284
|
+
#boxObserver: ResizeObserver | null = null;
|
|
285
|
+
|
|
286
|
+
#namespace = DEFAULT_STORAGE_NAMESPACE;
|
|
287
|
+
#storagePath = `${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`;
|
|
288
|
+
#workDir = `${WORK_ROOT}/${ENGINE_ID}/${DEFAULT_STORAGE_NAMESPACE}`;
|
|
289
|
+
#persist = true;
|
|
290
|
+
#saveTimer: ReturnType<typeof setInterval> | null = null;
|
|
291
|
+
#lastSaved = new Map<string, Uint8Array>();
|
|
292
|
+
#flushing: Promise<void> | null = null;
|
|
293
|
+
#onHidden = (): void => {
|
|
294
|
+
if (document.visibilityState === 'hidden') void this.flushSaves();
|
|
295
|
+
};
|
|
106
296
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
try {
|
|
110
|
-
Module.FS.stat(path);
|
|
111
|
-
return true;
|
|
112
|
-
} catch {
|
|
113
|
-
return false;
|
|
297
|
+
constructor(init?: EngineInit<Rsdkv5Payloads>, events?: EventEmitter<EventsOf<Rsdkv5Payloads>>) {
|
|
298
|
+
super(RSDKV5_SPEC, init, events);
|
|
114
299
|
}
|
|
115
|
-
}
|
|
116
300
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (!fileExists(Module, path)) return false;
|
|
120
|
-
try {
|
|
121
|
-
Module.FS.unlink(path);
|
|
122
|
-
return true;
|
|
123
|
-
} catch {
|
|
124
|
-
return false;
|
|
301
|
+
get namespace(): string {
|
|
302
|
+
return this.#namespace;
|
|
125
303
|
}
|
|
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
304
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
};
|
|
305
|
+
/** Where this session's files live, relative to the OPFS root: `rsdkv5/<namespace>`. */
|
|
306
|
+
get storagePath(): string {
|
|
307
|
+
return this.#storagePath;
|
|
308
|
+
}
|
|
144
309
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
};
|
|
310
|
+
get pausedBy(): string | null {
|
|
311
|
+
return this.#pauseOwner;
|
|
312
|
+
}
|
|
168
313
|
|
|
169
|
-
|
|
170
|
-
|
|
314
|
+
/** Mania Mode's save slots — see {@link ManiaSaves}. */
|
|
315
|
+
get saves(): ManiaSaves {
|
|
316
|
+
const Module = this.#module;
|
|
317
|
+
const workDir = this.#workDir;
|
|
318
|
+
const available = !!Module?._web_mania_save_status && !!Module._web_mania_start_slot;
|
|
319
|
+
return {
|
|
320
|
+
available,
|
|
321
|
+
characters: available && Module?._web_mania_characters ? Module._web_mania_characters() : 4,
|
|
322
|
+
list: () => {
|
|
323
|
+
let bytes: Uint8Array | null = null;
|
|
324
|
+
try {
|
|
325
|
+
bytes = Module ? new Uint8Array(Module.FS.readFile(`${workDir}/SaveData.bin`)) : null;
|
|
326
|
+
} catch {
|
|
327
|
+
bytes = null; // no save file yet: eight empty slots
|
|
328
|
+
}
|
|
329
|
+
return parseManiaSaves(bytes);
|
|
330
|
+
},
|
|
331
|
+
ready: async (timeoutMs = 5000) => {
|
|
332
|
+
if (!available || !Module) return false;
|
|
333
|
+
const deadline = performance.now() + timeoutMs;
|
|
334
|
+
for (;;) {
|
|
335
|
+
const status = Module._web_mania_save_status!();
|
|
336
|
+
if (status === 200) return true;
|
|
337
|
+
if ((status !== 0 && status !== 100) || performance.now() > deadline) return false;
|
|
338
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
start: (slot, character = 0) => {
|
|
342
|
+
if (!available || !Module) return false;
|
|
343
|
+
if (!Module._web_mania_start_slot!(slot | 0, character | 0)) return false;
|
|
344
|
+
// Whatever drove this is done with the engine: hand it back running.
|
|
345
|
+
if (this.#pauseOwner) this.resume(this.#pauseOwner);
|
|
346
|
+
return true;
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
171
350
|
|
|
172
|
-
|
|
173
|
-
|
|
351
|
+
/** RSDKv5's stage list, for a host that draws its own stage select. */
|
|
352
|
+
get devMenu(): RsdkDevMenuBridge {
|
|
353
|
+
const Module = (): Rsdkv5Module | null => this.#module;
|
|
354
|
+
return {
|
|
355
|
+
getStageList() {
|
|
356
|
+
try {
|
|
357
|
+
return JSON.parse(Module()?.web_devmenu_get_stage_list?.() ?? '[]');
|
|
358
|
+
} catch {
|
|
359
|
+
return [];
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
loadStage(category: number, stage: number) {
|
|
363
|
+
Module()?.web_devmenu_load_stage?.(category | 0, stage | 0);
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
174
367
|
|
|
175
|
-
|
|
176
|
-
|
|
368
|
+
/**
|
|
369
|
+
* Freeze the game and its sound. The first caller owns the pause; only that
|
|
370
|
+
* owner's {@link resume} lifts it. The save is flushed.
|
|
371
|
+
*/
|
|
372
|
+
pause(owner = 'host'): void {
|
|
373
|
+
const Module = this.#module;
|
|
374
|
+
if (!Module || this.#pauseOwner) return;
|
|
375
|
+
this.#pauseOwner = owner;
|
|
376
|
+
Module.web_devmenu_set_paused?.(true);
|
|
377
|
+
// This build has no native pause hook (web_devmenu_* are not compiled in), so the
|
|
378
|
+
// game is frozen by stopping emscripten's main loop: no frame runs until resume().
|
|
379
|
+
Module.pauseMainLoop?.();
|
|
380
|
+
void Module.SDL2?.audioContext?.suspend();
|
|
381
|
+
void this.flushSaves();
|
|
382
|
+
this.emit('pause', { owner });
|
|
383
|
+
}
|
|
177
384
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
385
|
+
resume(owner = 'host'): void {
|
|
386
|
+
const Module = this.#module;
|
|
387
|
+
if (!Module || this.#pauseOwner !== owner) return;
|
|
388
|
+
this.#pauseOwner = null;
|
|
389
|
+
if (!this.#muted) void Module.SDL2?.audioContext?.resume();
|
|
390
|
+
Module.web_devmenu_set_paused?.(false);
|
|
391
|
+
Module.resumeMainLoop?.();
|
|
392
|
+
this.emit('resume', { owner });
|
|
393
|
+
}
|
|
187
394
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
dataBytes = toUint8(await config.dataProvider());
|
|
395
|
+
/**
|
|
396
|
+
* Silence the game without stopping it (the frames keep running). A host uses it to
|
|
397
|
+
* let Mania get somewhere — load its save file — behind its own screen. Independent
|
|
398
|
+
* of {@link pause}: a paused game stays silent either way.
|
|
399
|
+
*/
|
|
400
|
+
mute(muted = true): void {
|
|
401
|
+
this.#muted = muted;
|
|
402
|
+
const context = this.#module?.SDL2?.audioContext;
|
|
403
|
+
if (muted) void context?.suspend();
|
|
404
|
+
else if (!this.#pauseOwner) void context?.resume();
|
|
199
405
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
throw new Error('rsdkv5: no Data.rsdk — provide assets.data or dataProvider');
|
|
406
|
+
|
|
407
|
+
get muted(): boolean {
|
|
408
|
+
return this.#muted;
|
|
204
409
|
}
|
|
205
410
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
411
|
+
/** Copy the game's changed user files to OPFS `rsdkv5/<namespace>/`; emits `saves`. */
|
|
412
|
+
flushSaves(): Promise<void> {
|
|
413
|
+
this.#flushing ??= this.#flush().finally(() => {
|
|
414
|
+
this.#flushing = null;
|
|
415
|
+
});
|
|
416
|
+
return this.#flushing;
|
|
210
417
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
418
|
+
|
|
419
|
+
// ---- Contract hooks ----
|
|
420
|
+
|
|
421
|
+
protected override mounted(target: HTMLElement): void {
|
|
422
|
+
if (target instanceof HTMLCanvasElement) {
|
|
423
|
+
this.#canvas = target;
|
|
424
|
+
this.#ownsCanvas = false;
|
|
425
|
+
this.#box = target.parentElement;
|
|
426
|
+
} else {
|
|
427
|
+
this.#canvas = document.createElement('canvas');
|
|
428
|
+
this.#ownsCanvas = true;
|
|
429
|
+
this.#box = target;
|
|
430
|
+
target.appendChild(this.#canvas);
|
|
431
|
+
}
|
|
432
|
+
// Emscripten's SDL2 port finds its window with querySelector('#canvas').
|
|
433
|
+
this.#canvas.id = 'canvas';
|
|
434
|
+
this.#canvas.tabIndex = 0;
|
|
435
|
+
this.#canvas.style.display = 'block';
|
|
436
|
+
this.#canvas.style.imageRendering = 'pixelated';
|
|
437
|
+
this.#canvas.addEventListener('contextmenu', (event) => event.preventDefault());
|
|
438
|
+
if (this.#ownsCanvas && this.#box) {
|
|
439
|
+
this.#boxObserver?.disconnect();
|
|
440
|
+
this.#boxObserver = new ResizeObserver(() => this.#fit());
|
|
441
|
+
this.#boxObserver.observe(this.#box);
|
|
442
|
+
}
|
|
215
443
|
}
|
|
216
444
|
|
|
217
|
-
|
|
445
|
+
protected override async open(_options: Rsdkv5Options): Promise<void> {
|
|
446
|
+
if (this.#destroyed || this.#module) {
|
|
447
|
+
// RSDKv5 cannot restart in process: main() runs once and owns the loop.
|
|
448
|
+
throw new Error('rsdkv5: the engine cannot be restarted in process — destroy() and start a new page (or iframe)');
|
|
449
|
+
}
|
|
450
|
+
await this.#boot();
|
|
451
|
+
}
|
|
218
452
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
453
|
+
protected override patched(_kind: PayloadKind, _patch: object): void {
|
|
454
|
+
// Nothing in RSDKV5_SPEC is live: Settings.ini is read once, at boot.
|
|
455
|
+
}
|
|
222
456
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
457
|
+
protected override async close(): Promise<void> {
|
|
458
|
+
if (this.#saveTimer) clearInterval(this.#saveTimer);
|
|
459
|
+
this.#saveTimer = null;
|
|
460
|
+
document.removeEventListener('visibilitychange', this.#onHidden);
|
|
461
|
+
window.removeEventListener('pagehide', this.#onHidden);
|
|
462
|
+
const Module = this.#module;
|
|
463
|
+
if (Module) {
|
|
464
|
+
await this.flushSaves();
|
|
226
465
|
try {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
466
|
+
Module.web_devmenu_set_paused?.(true);
|
|
467
|
+
Module.pauseMainLoop?.();
|
|
468
|
+
} catch {
|
|
469
|
+
/* already stopped */
|
|
231
470
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
471
|
+
try {
|
|
472
|
+
await Module.SDL2?.audioContext?.close();
|
|
473
|
+
} catch {
|
|
474
|
+
/* already closed */
|
|
236
475
|
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
|
|
476
|
+
}
|
|
477
|
+
this.#boxObserver?.disconnect();
|
|
478
|
+
this.#boxObserver = null;
|
|
479
|
+
if (this.#ownsCanvas) this.#canvas?.remove();
|
|
480
|
+
this.#canvas = null;
|
|
481
|
+
this.#module = null;
|
|
482
|
+
this.#pauseOwner = null;
|
|
483
|
+
this.#destroyed = true;
|
|
484
|
+
}
|
|
240
485
|
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
start()
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
486
|
+
// ---- Boot ----
|
|
487
|
+
|
|
488
|
+
async #boot(): Promise<void> {
|
|
489
|
+
const config = await this.#values<Rsdkv5Config>('config');
|
|
490
|
+
const assets = await this.#values<{ data?: Rsdkv5Asset; settings?: Rsdkv5Asset }>('assets');
|
|
491
|
+
const storage = await this.#values<{ namespace?: string; persist?: 'opfs' | null }>('storage');
|
|
492
|
+
if (!this.#canvas) throw new Error('rsdkv5: mount() an element (or a canvas) before start()');
|
|
493
|
+
|
|
494
|
+
const data = await resolveAsset(assets.data);
|
|
495
|
+
if (!data) throw new Error('rsdkv5: no Data.rsdk — hand one over as assets.data (bytes or a thunk)');
|
|
496
|
+
const settings = await resolveAsset(assets.settings);
|
|
497
|
+
|
|
498
|
+
const resolved = storagePath(ENGINE_ID, storage.namespace);
|
|
499
|
+
if (resolved.prefixed) {
|
|
500
|
+
console.warn(`[rsdkv5] storage.namespace no longer needs the "${ENGINE_ID}/" prefix. Pass "${resolved.namespace}".`);
|
|
501
|
+
}
|
|
502
|
+
this.#namespace = resolved.namespace;
|
|
503
|
+
this.#storagePath = resolved.path;
|
|
504
|
+
this.#workDir = `${WORK_ROOT}/${resolved.path}`;
|
|
505
|
+
this.#persist = storage.persist !== null;
|
|
506
|
+
|
|
507
|
+
const jsUrl = config.jsUrl ?? new URL('./rsdkv5.js', import.meta.url).href;
|
|
508
|
+
const wasmUrl = config.wasmUrl ?? new URL('./rsdkv5.wasm', import.meta.url).href;
|
|
509
|
+
const { default: createRSDKv5 } = (await import(/* @vite-ignore */ jsUrl)) as { default: Rsdkv5Factory };
|
|
510
|
+
|
|
511
|
+
let failed: ((error: Error) => void) | null = null;
|
|
512
|
+
const Module = await createRSDKv5({
|
|
513
|
+
canvas: this.#canvas,
|
|
514
|
+
noInitialRun: true,
|
|
515
|
+
locateFile: (path: string) => (path.endsWith('.wasm') ? wasmUrl : new URL(path, jsUrl).href),
|
|
516
|
+
print: (text: string) => console.log('[rsdkv5]', text),
|
|
517
|
+
printErr: (text: string) => console.warn('[rsdkv5]', text),
|
|
518
|
+
onAbort: (what: unknown) => failed?.(new Error(`rsdkv5: aborted — ${String(what)}`)),
|
|
519
|
+
});
|
|
520
|
+
this.#module = Module;
|
|
521
|
+
failed = (error) => this.emit('error', error);
|
|
522
|
+
|
|
523
|
+
mkdirp(Module, this.#workDir);
|
|
524
|
+
Module.FS.writeFile(`${this.#workDir}/Data.rsdk`, data);
|
|
525
|
+
Module.FS.writeFile(
|
|
526
|
+
`${this.#workDir}/Settings.ini`,
|
|
527
|
+
settings ?? new TextEncoder().encode(buildSettingsIni(config)),
|
|
528
|
+
);
|
|
529
|
+
if (this.#persist) await this.#restoreSave();
|
|
530
|
+
|
|
531
|
+
Module.FS.chdir(this.#workDir);
|
|
532
|
+
// RSDKv5U resolves Settings.ini and dataFile relative to the CWD set above.
|
|
533
|
+
Module.callMain([]);
|
|
534
|
+
|
|
535
|
+
if (this.#persist) {
|
|
536
|
+
this.#saveTimer = setInterval(() => void this.flushSaves(), SAVE_INTERVAL_MS);
|
|
537
|
+
document.addEventListener('visibilitychange', this.#onHidden);
|
|
538
|
+
window.addEventListener('pagehide', this.#onHidden);
|
|
539
|
+
}
|
|
540
|
+
this.#fit();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async #values<T>(kind: 'config' | 'assets' | 'storage'): Promise<T> {
|
|
544
|
+
const described = (await this[kind]()) as Record<string, { value: unknown }>;
|
|
545
|
+
const out: Record<string, unknown> = {};
|
|
546
|
+
for (const [key, property] of Object.entries(described)) out[key] = property.value;
|
|
547
|
+
return out as T;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ---- Save mirror ----
|
|
551
|
+
|
|
552
|
+
async #restoreSave(): Promise<void> {
|
|
553
|
+
const dir = await opfsDir(this.#storagePath, false);
|
|
554
|
+
if (!dir || !this.#module) return;
|
|
555
|
+
const iterable = dir as unknown as { entries(): AsyncIterable<[string, FileSystemHandle]> };
|
|
556
|
+
for await (const [name, handle] of iterable.entries()) {
|
|
557
|
+
if (handle.kind !== 'file' || NOT_SAVES.has(name)) continue;
|
|
558
|
+
const bytes = new Uint8Array(await (await (handle as FileSystemFileHandle).getFile()).arrayBuffer());
|
|
559
|
+
this.#module.FS.writeFile(`${this.#workDir}/${name}`, bytes);
|
|
560
|
+
this.#lastSaved.set(name, bytes);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async #flush(): Promise<void> {
|
|
565
|
+
const Module = this.#module;
|
|
566
|
+
if (!Module || !this.#persist) return;
|
|
567
|
+
const changed: Array<[string, Uint8Array<ArrayBuffer>]> = [];
|
|
568
|
+
let names: string[];
|
|
569
|
+
try {
|
|
570
|
+
names = Module.FS.readdir(this.#workDir);
|
|
571
|
+
} catch {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
for (const name of names) {
|
|
575
|
+
if (name === '.' || name === '..' || NOT_SAVES.has(name)) continue;
|
|
576
|
+
const path = `${this.#workDir}/${name}`;
|
|
577
|
+
if (!Module.FS.isFile(Module.FS.stat(path).mode)) continue;
|
|
578
|
+
const bytes = new Uint8Array(Module.FS.readFile(path));
|
|
579
|
+
if (!sameBytes(this.#lastSaved.get(name) ?? null, bytes)) changed.push([name, bytes]);
|
|
580
|
+
}
|
|
581
|
+
if (changed.length === 0) return;
|
|
582
|
+
const dir = await opfsDir(this.#storagePath, true);
|
|
583
|
+
if (!dir) return;
|
|
584
|
+
const written: string[] = [];
|
|
585
|
+
for (const [name, bytes] of changed) {
|
|
586
|
+
try {
|
|
587
|
+
const writable = await (await dir.getFileHandle(name, { create: true })).createWritable();
|
|
588
|
+
await writable.write(bytes);
|
|
589
|
+
await writable.close();
|
|
590
|
+
this.#lastSaved.set(name, bytes);
|
|
591
|
+
written.push(name);
|
|
592
|
+
} catch (e) {
|
|
593
|
+
console.warn(`[rsdkv5] could not persist ${name}`, e);
|
|
256
594
|
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
|
|
595
|
+
}
|
|
596
|
+
if (written.length) this.emit('saves', { files: written });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ---- Picture ----
|
|
600
|
+
|
|
601
|
+
#fit(): void {
|
|
602
|
+
const canvas = this.#canvas;
|
|
603
|
+
const box = this.#box;
|
|
604
|
+
if (!canvas || !box || !this.#ownsCanvas) return;
|
|
605
|
+
const { width, height } = box.getBoundingClientRect();
|
|
606
|
+
if (!width || !height) return;
|
|
607
|
+
const scale = Math.min(width / RSDKV5_VIDEO.width, height / RSDKV5_VIDEO.height);
|
|
608
|
+
canvas.style.width = `${Math.floor(RSDKV5_VIDEO.width * scale)}px`;
|
|
609
|
+
canvas.style.height = `${Math.floor(RSDKV5_VIDEO.height * scale)}px`;
|
|
610
|
+
}
|
|
272
611
|
}
|
|
273
612
|
|
|
274
|
-
|
|
613
|
+
/** The factory a host constructs: static identity, the configuration chain, `start()`. */
|
|
614
|
+
export class Rsdkv5SDK extends EngineSDKBase<Rsdkv5Payloads> {
|
|
615
|
+
static readonly id = ENGINE_ID;
|
|
616
|
+
/** Kept in step with package.json by `node scripts/sync-version.mjs`. */
|
|
617
|
+
static readonly version = '0.2.0';
|
|
618
|
+
static readonly name = 'Retro Software Development Kit v3';
|
|
619
|
+
static readonly description =
|
|
620
|
+
'RSDKv5U (Sonic Mania decompilation) compiled to WebAssembly. Ships none of the game: the player provides Data.rsdk, and it never leaves their browser.';
|
|
621
|
+
|
|
622
|
+
protected override createPlay(
|
|
623
|
+
init: EngineInit<Rsdkv5Payloads>,
|
|
624
|
+
events: EventEmitter<EventsOf<Rsdkv5Payloads>>,
|
|
625
|
+
): Rsdkv5Play {
|
|
626
|
+
return new Rsdkv5Play(init, events);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** CI's conformance line: if this class stops satisfying the contract, `make test` fails. */
|
|
631
|
+
export const RSDKV5_ENGINE: EngineSDKConstructor<Rsdkv5Payloads> = Rsdkv5SDK;
|
|
632
|
+
|
|
633
|
+
export default Rsdkv5SDK;
|