@wasm-gaming/rsdkv5-wasm 0.1.2 → 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 +27 -0
- 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/rsdkv5.js +1 -1
- package/dist/rsdkv5/rsdkv5.sdk.d.ts +58 -1
- package/dist/rsdkv5/rsdkv5.sdk.d.ts.map +1 -1
- package/dist/rsdkv5/rsdkv5.sdk.js +107 -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 +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.sdk.ts +145 -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
|
|
@@ -191,6 +276,7 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
|
|
|
191
276
|
#module: Rsdkv5Module | null = null;
|
|
192
277
|
#destroyed = false;
|
|
193
278
|
#pauseOwner: string | null = null;
|
|
279
|
+
#muted = false;
|
|
194
280
|
|
|
195
281
|
#canvas: HTMLCanvasElement | null = null;
|
|
196
282
|
#ownsCanvas = false;
|
|
@@ -225,6 +311,43 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
|
|
|
225
311
|
return this.#pauseOwner;
|
|
226
312
|
}
|
|
227
313
|
|
|
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
|
+
}
|
|
350
|
+
|
|
228
351
|
/** RSDKv5's stage list, for a host that draws its own stage select. */
|
|
229
352
|
get devMenu(): RsdkDevMenuBridge {
|
|
230
353
|
const Module = (): Rsdkv5Module | null => this.#module;
|
|
@@ -251,6 +374,9 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
|
|
|
251
374
|
if (!Module || this.#pauseOwner) return;
|
|
252
375
|
this.#pauseOwner = owner;
|
|
253
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?.();
|
|
254
380
|
void Module.SDL2?.audioContext?.suspend();
|
|
255
381
|
void this.flushSaves();
|
|
256
382
|
this.emit('pause', { owner });
|
|
@@ -260,11 +386,28 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
|
|
|
260
386
|
const Module = this.#module;
|
|
261
387
|
if (!Module || this.#pauseOwner !== owner) return;
|
|
262
388
|
this.#pauseOwner = null;
|
|
263
|
-
void Module.SDL2?.audioContext?.resume();
|
|
389
|
+
if (!this.#muted) void Module.SDL2?.audioContext?.resume();
|
|
264
390
|
Module.web_devmenu_set_paused?.(false);
|
|
391
|
+
Module.resumeMainLoop?.();
|
|
265
392
|
this.emit('resume', { owner });
|
|
266
393
|
}
|
|
267
394
|
|
|
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();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
get muted(): boolean {
|
|
408
|
+
return this.#muted;
|
|
409
|
+
}
|
|
410
|
+
|
|
268
411
|
/** Copy the game's changed user files to OPFS `rsdkv5/<namespace>/`; emits `saves`. */
|
|
269
412
|
flushSaves(): Promise<void> {
|
|
270
413
|
this.#flushing ??= this.#flush().finally(() => {
|
|
@@ -471,7 +614,7 @@ export class Rsdkv5Play extends EnginePlayBase<Rsdkv5Payloads> {
|
|
|
471
614
|
export class Rsdkv5SDK extends EngineSDKBase<Rsdkv5Payloads> {
|
|
472
615
|
static readonly id = ENGINE_ID;
|
|
473
616
|
/** Kept in step with package.json by `node scripts/sync-version.mjs`. */
|
|
474
|
-
static readonly version = '0.
|
|
617
|
+
static readonly version = '0.2.0';
|
|
475
618
|
static readonly name = 'Retro Software Development Kit v3';
|
|
476
619
|
static readonly description =
|
|
477
620
|
'RSDKv5U (Sonic Mania decompilation) compiled to WebAssembly. Ships none of the game: the player provides Data.rsdk, and it never leaves their browser.';
|