@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/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.2';
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.';