@wasm-gaming/rsdkv5-wasm 0.1.2 → 0.2.1
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 +27 -0
- package/dist/rsdkv5/app/app.css +192 -0
- package/dist/rsdkv5/app/app.js +533 -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/rsdkv5.js +1 -1
- package/dist/rsdkv5/rsdkv5.sdk.d.ts +77 -1
- package/dist/rsdkv5/rsdkv5.sdk.d.ts.map +1 -1
- package/dist/rsdkv5/rsdkv5.sdk.js +131 -2
- package/dist/rsdkv5/rsdkv5.sdk.js.map +1 -1
- package/dist/rsdkv5/rsdkv5.wasm +0 -0
- package/package.json +5 -1
- package/src/app/app.css +192 -0
- package/src/app/app.js +533 -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.sdk.ts +170 -2
package/src/rsdkv5.sdk.ts
CHANGED
|
@@ -71,9 +71,13 @@ interface Rsdkv5Module {
|
|
|
71
71
|
};
|
|
72
72
|
callMain(args: string[]): void;
|
|
73
73
|
pauseMainLoop?: () => void;
|
|
74
|
+
resumeMainLoop?: () => void;
|
|
74
75
|
web_devmenu_get_stage_list?: () => string;
|
|
75
76
|
web_devmenu_load_stage?: (category: number, stage: number) => void;
|
|
76
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;
|
|
77
81
|
SDL2?: { audioContext?: AudioContext };
|
|
78
82
|
}
|
|
79
83
|
|
|
@@ -85,6 +89,87 @@ export interface RsdkDevMenuBridge {
|
|
|
85
89
|
loadStage(category: number, stage: number): void;
|
|
86
90
|
}
|
|
87
91
|
|
|
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
|
+
}
|
|
117
|
+
|
|
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
|
|
122
|
+
|
|
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
|
+
}
|
|
172
|
+
|
|
88
173
|
/**
|
|
89
174
|
* Where an engine keeps one namespace: `<engineId>/<namespace>`, relative to the OPFS
|
|
90
175
|
* root. Identical in every wasm-gaming engine, written to move verbatim into
|
|
@@ -110,6 +195,26 @@ export function storagePath(
|
|
|
110
195
|
return { namespace: cleaned, path: `${engineId}/${cleaned}`, prefixed };
|
|
111
196
|
}
|
|
112
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Player 1's keyboard in the generated `Settings.ini` (`[Keyboard Map 1]`): arrows, A/S/D
|
|
200
|
+
* for buttons A/B/C, Z/X/C for X/Y/Z, Enter = Start, Tab = Select. Windows virtual-key
|
|
201
|
+
* codes, which is what RSDKv5's input layer maps from.
|
|
202
|
+
*/
|
|
203
|
+
export const KEYBOARD_MAP_1 = {
|
|
204
|
+
up: 0x26,
|
|
205
|
+
down: 0x28,
|
|
206
|
+
left: 0x25,
|
|
207
|
+
right: 0x27,
|
|
208
|
+
buttonA: 0x41, // A
|
|
209
|
+
buttonB: 0x53, // S
|
|
210
|
+
buttonC: 0x44, // D
|
|
211
|
+
buttonX: 0x5a, // Z
|
|
212
|
+
buttonY: 0x58, // X
|
|
213
|
+
buttonZ: 0x43, // C
|
|
214
|
+
start: 0x0d, // Enter
|
|
215
|
+
select: 0x09, // Tab
|
|
216
|
+
} as const;
|
|
217
|
+
|
|
113
218
|
/**
|
|
114
219
|
* Serialise the engine config into RSDKv5U's `Settings.ini` (capital S — the engine
|
|
115
220
|
* opens exactly that name, and the Emscripten FS is case-sensitive). The v5 dialect:
|
|
@@ -144,6 +249,11 @@ export function buildSettingsIni(config: Rsdkv5Config): string {
|
|
|
144
249
|
'streamVolume=0.8',
|
|
145
250
|
'sfxVolume=1.0',
|
|
146
251
|
'',
|
|
252
|
+
// Player 1's keys, the same in the three Sonics: A/S/D = A/B/C (RSDKv5's own default),
|
|
253
|
+
// Z/X/C = X/Y/Z (instead of Q/W/E). Values are the Windows virtual-key codes RSDKv5 reads.
|
|
254
|
+
'[Keyboard Map 1]',
|
|
255
|
+
...Object.entries(KEYBOARD_MAP_1).map(([key, vk]) => `${key}=0x${vk.toString(16)}`),
|
|
256
|
+
'',
|
|
147
257
|
].join('\n');
|
|
148
258
|
}
|
|
149
259
|
|
|
@@ -191,6 +301,7 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
|
|
|
191
301
|
#module: Rsdkv5Module | null = null;
|
|
192
302
|
#destroyed = false;
|
|
193
303
|
#pauseOwner: string | null = null;
|
|
304
|
+
#muted = false;
|
|
194
305
|
|
|
195
306
|
#canvas: HTMLCanvasElement | null = null;
|
|
196
307
|
#ownsCanvas = false;
|
|
@@ -225,6 +336,43 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
|
|
|
225
336
|
return this.#pauseOwner;
|
|
226
337
|
}
|
|
227
338
|
|
|
339
|
+
/** Mania Mode's save slots — see {@link ManiaSaves}. */
|
|
340
|
+
get saves(): ManiaSaves {
|
|
341
|
+
const Module = this.#module;
|
|
342
|
+
const workDir = this.#workDir;
|
|
343
|
+
const available = !!Module?._web_mania_save_status && !!Module._web_mania_start_slot;
|
|
344
|
+
return {
|
|
345
|
+
available,
|
|
346
|
+
characters: available && Module?._web_mania_characters ? Module._web_mania_characters() : 4,
|
|
347
|
+
list: () => {
|
|
348
|
+
let bytes: Uint8Array | null = null;
|
|
349
|
+
try {
|
|
350
|
+
bytes = Module ? new Uint8Array(Module.FS.readFile(`${workDir}/SaveData.bin`)) : null;
|
|
351
|
+
} catch {
|
|
352
|
+
bytes = null; // no save file yet: eight empty slots
|
|
353
|
+
}
|
|
354
|
+
return parseManiaSaves(bytes);
|
|
355
|
+
},
|
|
356
|
+
ready: async (timeoutMs = 5000) => {
|
|
357
|
+
if (!available || !Module) return false;
|
|
358
|
+
const deadline = performance.now() + timeoutMs;
|
|
359
|
+
for (;;) {
|
|
360
|
+
const status = Module._web_mania_save_status!();
|
|
361
|
+
if (status === 200) return true;
|
|
362
|
+
if ((status !== 0 && status !== 100) || performance.now() > deadline) return false;
|
|
363
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
start: (slot, character = 0) => {
|
|
367
|
+
if (!available || !Module) return false;
|
|
368
|
+
if (!Module._web_mania_start_slot!(slot | 0, character | 0)) return false;
|
|
369
|
+
// Whatever drove this is done with the engine: hand it back running.
|
|
370
|
+
if (this.#pauseOwner) this.resume(this.#pauseOwner);
|
|
371
|
+
return true;
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
228
376
|
/** RSDKv5's stage list, for a host that draws its own stage select. */
|
|
229
377
|
get devMenu(): RsdkDevMenuBridge {
|
|
230
378
|
const Module = (): Rsdkv5Module | null => this.#module;
|
|
@@ -251,6 +399,9 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
|
|
|
251
399
|
if (!Module || this.#pauseOwner) return;
|
|
252
400
|
this.#pauseOwner = owner;
|
|
253
401
|
Module.web_devmenu_set_paused?.(true);
|
|
402
|
+
// This build has no native pause hook (web_devmenu_* are not compiled in), so the
|
|
403
|
+
// game is frozen by stopping emscripten's main loop: no frame runs until resume().
|
|
404
|
+
Module.pauseMainLoop?.();
|
|
254
405
|
void Module.SDL2?.audioContext?.suspend();
|
|
255
406
|
void this.flushSaves();
|
|
256
407
|
this.emit('pause', { owner });
|
|
@@ -260,11 +411,28 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
|
|
|
260
411
|
const Module = this.#module;
|
|
261
412
|
if (!Module || this.#pauseOwner !== owner) return;
|
|
262
413
|
this.#pauseOwner = null;
|
|
263
|
-
void Module.SDL2?.audioContext?.resume();
|
|
414
|
+
if (!this.#muted) void Module.SDL2?.audioContext?.resume();
|
|
264
415
|
Module.web_devmenu_set_paused?.(false);
|
|
416
|
+
Module.resumeMainLoop?.();
|
|
265
417
|
this.emit('resume', { owner });
|
|
266
418
|
}
|
|
267
419
|
|
|
420
|
+
/**
|
|
421
|
+
* Silence the game without stopping it (the frames keep running). A host uses it to
|
|
422
|
+
* let Mania get somewhere — load its save file — behind its own screen. Independent
|
|
423
|
+
* of {@link pause}: a paused game stays silent either way.
|
|
424
|
+
*/
|
|
425
|
+
mute(muted = true): void {
|
|
426
|
+
this.#muted = muted;
|
|
427
|
+
const context = this.#module?.SDL2?.audioContext;
|
|
428
|
+
if (muted) void context?.suspend();
|
|
429
|
+
else if (!this.#pauseOwner) void context?.resume();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
get muted(): boolean {
|
|
433
|
+
return this.#muted;
|
|
434
|
+
}
|
|
435
|
+
|
|
268
436
|
/** Copy the game's changed user files to OPFS `rsdkv5/<namespace>/`; emits `saves`. */
|
|
269
437
|
flushSaves(): Promise<void> {
|
|
270
438
|
this.#flushing ??= this.#flush().finally(() => {
|
|
@@ -471,7 +639,7 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
|
|
|
471
639
|
export class Rsdkv5SDK extends EngineSDKBase<Rsdkv5Payloads> {
|
|
472
640
|
static readonly id = ENGINE_ID;
|
|
473
641
|
/** Kept in step with package.json by `node scripts/sync-version.mjs`. */
|
|
474
|
-
static readonly version = '0.1
|
|
642
|
+
static readonly version = '0.2.1';
|
|
475
643
|
static readonly name = 'Retro Software Development Kit v3';
|
|
476
644
|
static readonly description =
|
|
477
645
|
'RSDKv5U (Sonic Mania decompilation) compiled to WebAssembly. Ships none of the game: the player provides Data.rsdk, and it never leaves their browser.';
|